Compare commits

...

34 Commits

Author SHA1 Message Date
548556d4fc fix(ar1): popup compliance solo per ROLE_BENEFICIARY/ROLE_CONFIDI
Il popup 'Dichiarazione AR1 - Adeguata Verifica' veniva mostrato anche
ad admin / istruttore / direttore. AR1 (D.Lgs.231/2007) si applica solo
alle aziende beneficiarie: admin, istruttore manager, pre-istruttore
e direttore non hanno una azienda da dichiarare.

- whitelist AR1_POPUP_ALLOWED_ROLES = [ROLE_BENEFICIARY, ROLE_CONFIDI]
- gate role nel useEffect (exit early se ruolo non ammesso)
- role aggiunto a deps array

Difesa in profondita: il gate vive dentro il componente, quindi resta
attivo a prescindere da come/dove viene montato a monte.
2026-05-08 07:55:55 +02:00
ECO
fba47c6e77 fix(ar1): upload file sostituibile in Quadro F + Signature
Quadro F (Ar1Wizard.js): se uno slot ha gia un file selezionato si mostra
preview con pulsante Rimuovi che azzera lo state, invece di FileUpload
basic+auto+customUpload che dopo la prima selezione non riapre il picker.

Signature (Ar1Signature.js): aggiunti useRef + key={uploadAttempt} dinamica
sul FileUpload + helper resetUploadInput() chiamato in success/error/guard
ext, per forzare il remount e permettere di ricaricare un nuovo file senza
dover navigare via Torna alla Home.

Bug segnalati da test sandbox 2026-05-06.
2026-05-06 14:36:09 +02:00
BFLOWS
09da2b7c25 fix(quadri-editor): bottone 'Aggiungi campo' sempre visibile (anche su Quadri B/F/G)
Bug: nei quadri B (titolari row_type), F (upload_slots), G (legal frame), la key
'fields' non esisteva nel questions_snapshot → la condizione (q.fields !== undefined)
nascondeva tutto il blocco 'Campi' incluso il bottone 'Aggiungi campo'. Carlo non
poteva aggiungere campi normali a questi 3 quadri.

Fix:
  - Rimosso condizionale (q.fields !== undefined): blocco 'Campi' sempre renderizzato
  - Count dinamico '(q.fields || []).length' mostra 0 se non esiste
  - Messaggio 'Nessun campo diretto. Aggiungine uno col bottone...' contestuale:
    - Se row_type presente -> 'o usa i Campi riga per i titolari_effettivi'
    - Se nested_full presente -> 'o usa i Campi annidati qui sotto'
  - addField ora genera id univoco con prefisso quadro:
    'campo_a_17', 'campo_b_1', 'campo_g_1' invece del semplice 'campo_1' che
    poteva collidere tra quadri dello stesso template
2026-04-23 16:30:57 +02:00
BFLOWS
8f9e3d5622 fix(ar1-admin): TabView interno per Layout vs Struttura quadri (prima erano Button invisibili)
I due 'tab' interni al Dialog erano <Button> con prop text=true quando non selezionati:
rendering senza sfondo ne bordo, praticamente invisibili. Carlo non vedeva come
raggiungere l'editor Struttura quadri e pensava mancasse.

Sostituito l'hacky Button-tab con un vero TabView PrimeReact (stesso componente
del TabView esterno delle 5 tab). Due TabPanel:
  1. 'Layout grafico' (icona pi pi-palette) - contenuto form brand/header/intro/privacy
  2. 'Struttura quadri' (icona pi pi-list, + ' •' se modificata) - rende QuadriStructureEditor

Mapping activeIndex<->editSection:
  editSection='layout' -> activeIndex=0
  editSection='struttura' -> activeIndex=1
  onTabChange setta entrambi.
2026-04-23 16:02:13 +02:00
BFLOWS
3ae5aabe2d fix(ar1-admin): TypeError Ar1Service.getTemplate is not a function
Errore runtime al click 'Modifica' sul template: avevo chiamato Ar1Service.getTemplate
ma nel service esistente il metodo si chiama getTemplateDetail. Inoltre getNextVersion
non era mai stato aggiunto al service (il mio replace precedente non ha matchato la
signature di listTemplates che include un terzo parametro queryParams).

Fix:
  - openEditTemplate ora chiama Ar1Service.getTemplateDetail (metodo esistente)
  - Aggiunto Ar1Service.getNextVersion(variant) che chiama GET /admin/ar1-templates/
    :variant/next-version (endpoint BE gia live)
2026-04-23 15:50:38 +02:00
BFLOWS
ec0e7397e6 feat(ar1-admin): editor unificato template (layout L2 + struttura quadri L1)
Strada A: il superadmin puo modificare TUTTO via UI (layout grafico + struttura
dei quadri). Se tocca solo il layout -> PUT in place. Se tocca la struttura ->
il BE auto-bumpa la patch e archivia la versione precedente. I form gia compilati
continuano a usare il loro snapshot.

Nuovo componente:
  src/modules/ar1/components/QuadriStructureEditor.js (438 LOC)
  - Metadati snapshot: variant_label, legal_ref, normative_frame, variant_description
  - Lista quadri in Accordion, per ogni quadro:
      * id / title / description modificabili
      * reorder su/giu, elimina quadro, aggiungi quadro
      * Warning 'NORMATIVO' per Quadro G (is_legal_frame=true)
      * Fields normali: editor per-campo con id, label, tipo (7 types), required,
        max_length, pattern regex, options (per enum/radio), tag prefill_from
      * Row fields (row_type, Quadro B titolari): sezione separata con warning
      * Nested_full fields (Quadro C/D): sezione separata
      * Upload slots (Quadro F): tag readonly (edit avanzato tbd)
  - FIELD_TYPE_OPTIONS: text, email, date, checkbox, radio, enum, yes_no_with_note
  - Usa Accordion multi-open per navigare piu quadri, Tag per metadati visuali

Cambiamenti in Ar1AdminConfig.js:
  - Rimossi: openEditLayout, openNewVersion, saveNewVersion, stati newVersionOpen/
    Data/Variant, Dialog 'Nuova versione' manuale (user sceglieva version semver)
  - Aggiunti: openEditTemplate (carica template completo via GET detail),
    saveEditTemplate (fa diff questions_snapshot, se cambiato chiama
    createNewTemplateVersion senza version -> BE auto-bump, se invariato chiama
    updateTemplateLayout in place), questionsStructureChanged helper (deep-equal
    via JSON.stringify su clone deep fatto al load)
  - Service: + getTemplate + getNextVersion (per preview numero versione)
  - Bottoni azioni tab Template: solo 'Anteprima' + 'Modifica' (rimosso '+ Nuova vers.')
  - Dialog unificato 1100px maximizable:
      * Bar top con Tag variante/version/status + Message warning se struttura
        modificata (mostra prossima versione preview es. v1.2.968)
      * 2 tab interni (pulsanti custom): 'Layout grafico' vs 'Struttura quadri'
        con indicatore • se struttura ha modifiche
      * Sezione Layout: form come prima (brand/header/intro/privacy) + toggle JSON raw
      * Sezione Struttura: rende QuadriStructureEditor
      * Footer sticky: tag stato ('update in place' verde vs 'nuova versione' giallo)
        + bottone Salva che cambia label e severity: 'Salva layout' default vs
        'Crea versione v1.2.968' warning quando struttura cambiata
  - Dialog 'Nuova versione' rimosso (mai piu input manuale di semver)
2026-04-23 15:46:43 +02:00
BFLOWS
ac1c18c737 feat(ar1-admin): messaggi errore Pydantic tradotti in italiano umano
Ora il toast error mostra es. 'Versione: deve essere nel formato X.Y.Z
(esempio: 1.1.0)' invece di 'version: String should match pattern
^\d+\.\d+\.\d+$'.

2 nuove utility:
  - FIELD_LABELS_IT: mapping field name -> label IT (version -> 'Versione',
    kind -> 'Tipo regola', subject -> 'Oggetto', body_html -> 'Corpo HTML',
    validity_days -> 'Validita', ecc. — 20 campi mappati)
  - translatePydanticMsg(msg, type, ctx): riconosce i type Pydantic comuni:
      * string_pattern_mismatch + ctx.pattern semver -> 'deve essere nel
        formato X.Y.Z (esempio: 1.1.0)'
      * missing -> 'campo obbligatorio mancante'
      * string_type / int_type / bool_type -> 'deve essere stringa/intero/bool'
      * greater_than_equal / less_than_equal -> 'deve essere almeno N / al
        massimo N' (usando ctx.ge / ctx.le)
      * string_too_short / too_long -> 'troppo corto/lungo (min/max N caratteri)'
      * value_error -> rimuove il prefisso 'Value error, '
      * fallback: msg originale (non rompe nulla per casi non mappati)

formatErrorDetail ora usa entrambi: estrae l'ultimo loc (field name piu
preciso), lo traduce via FIELD_LABELS_IT, concatena col msg tradotto.
2026-04-23 15:27:30 +02:00
BFLOWS
cad839aea0 fix(ar1-admin): crash su error Pydantic (detail array) -> formatErrorDetail helper
Bug: click 'Crea versione' con version vuota o invalida -> pagina bianca + 'error'.
Causa: il BE restituisce 422 con detail come ARRAY Pydantic [{loc, msg, type}].
Il codice faceva detail: err?.detail || 'fallback' -> array passato a PrimeReact Toast
-> React render 'Objects are not valid as a React child' -> crash unhandled.

Fix: helper formatErrorDetail(detail, fallback) che normalizza:
  - string -> ritorna direttamente
  - array Pydantic -> 'loc.loc: msg; loc.loc: msg' (filtrato 'body')
  - object -> JSON.stringify
  - altro -> String(x)

Applicato con regex a tutti i pattern 'err?.detail || "fallback"' nel file (tutti
i toast show di errore nei vari handler: saveLayout, saveNewVersion, savePolicy,
savePecRule, deletePecRule, saveEmail, runPreview, runBulk, loadXxx).
2026-04-23 15:24:05 +02:00
BFLOWS
84ada138f2 fix(ar1-admin): tab icon spacing via marginRight inline (primeflex non disponibile)
Il tentativo precedente usava la classe 'mr-2' (convenzione Primeflex), ma
grep conferma che primeflex NON e incluso nel progetto (nessun import in src/,
assente da package.json). 'mr-2' era classe morta → icone restavano attaccate.

Pattern corretto del progetto (vedi Ar1ComplianceModal.js:73, SchemaTemplatePicker.js:111):
  <i className="pi pi-X" style={{ marginRight: '0.5rem' }} />

Soluzione: TabPanel 'leftIcon' stringa -> 'header' JSX con icona+label:
  header={<span><i className="pi pi-X" style={{marginRight:'0.5rem'}} />Label</span>}

Applicato ai 5 TabPanel (Template / Policy / Regole reminder / Invio massivo /
Testi comunicazioni).
2026-04-23 15:21:02 +02:00
BFLOWS
21c58311e2 style(ar1-admin): spazio tra icona e label nei TabPanel (classe mr-2)
PrimeReact leftIcon non ha margin-right di default, le icone erano incollate
al testo ('Template', 'Policy', etc). Aggiunta classe utility mr-2
(margin-right 0.5rem) su tutti e 5 i TabPanel, pattern identico a quello
usato in rendicontazione (es. IstruttoriaPratica.js linea con
leftIcon='pi pi-file mr-2').
2026-04-23 15:19:20 +02:00
BFLOWS
5bbf39488f feat(ar1-admin): variabili cliccabili nel dialog modifica testo email
I 7 Tag delle variabili disponibili (company_name, company_piva, ar1_form_url,
expires_at, days_to_expiry, variant, signer_name) sono ora CLICCABILI dentro
il dialog di modifica testo. Click inserisce {{variabile}} nel campo attivo
al punto del cursore (o in coda se focus perso).

Cambiamenti:
  - Stato nuovo: activeEmailField ('subject' | 'body_html' | 'body_text'),
    inizialmente 'body_html'
  - 3 ref: subjectInputRef, bodyHtmlInputRef, bodyTextInputRef
  - Funzione insertVariable(varName):
      1. Legge editEmailData[activeEmailField]
      2. Calcola posizione cursore via ref.getInput().selectionStart (fallback: coda)
      3. Inserisce '{{' + varName + '}}' al cursore
      4. Riposiziona cursor dopo il token inserito (setTimeout 0 per React re-render)
  - 3 campi Input wrappati con ref + onFocus={() => setActiveEmailField(...)}
  - Box variabili spostato DENTRO il dialog (prima era nel TabPanel, poco scopribile).
    Mostra live quale campo e attivo: 'inserite in: Oggetto / Corpo HTML / Corpo testo'
  - Tag stile severity=info + cursor:pointer + userSelect:none per feedback visuale
  - Box variabili nel tab ora e un semplice Message informativo (la funzione e nel dialog)

UX: l'utente clicca dentro Oggetto, poi clicca {{company_name}}, vede il token
comparire al cursore. Se clicca Corpo HTML, poi un'altra variabile, va in quel
campo. Nessun drag&drop, nessuna complicazione.
2026-04-23 15:15:18 +02:00
BFLOWS
c481871fa0 fix(ar1-admin): pattern bottoni azioni = copia esatta da IstruttoriaPratica
Errore: avevo usato 'rounded text' (senza bordo) o label esplicita (fuori standard progetto).
Carlo ha giustamente chiesto di copiare il pattern esistente. Pattern autentico delle
DataTable azioni in bflows-bandi-fe, verificato in rendicontazione/IstruttoriaPratica.js:

  <Button icon="pi pi-..." rounded outlined size="small" severity="..."
          tooltip="..." tooltipOptions={{ position: 'top' }}
          onClick={...} />

Cambiamenti:
  - pecActionsTpl: icon matita severity=info + icon cestino severity=danger, tooltip, gap 0.25rem
  - tplActiveActionsTpl: icon occhio info + matita default + plus warning, tooltip su ciascuno
  - emailActionsTpl: icon matita info (era Button con label fuori standard), wrappato in div
  - colonna pecActionsTpl width 200→130 (icon-only non serve spazio)
  - colonna tplActiveActionsTpl width 280→170 (stessa logica)

Icone sono primeicons (pi pi-*), NON lucide. In tutto il progetto si usa primeicons
(confermato grep globale: zero import lucide-react nel FE bflows-bandi-fe).
2026-04-23 15:08:36 +02:00
BFLOWS
7ea5d7fd4c fix(ar1-admin): bottoni azioni con label (pattern coerente rendicontazione)
Bug visuale: colonna Azioni appariva vuota. I Button rounded+text con solo icon=pi
pi-pencil (senza label) non renderizzavano su alcune configurazioni PrimeReact.
Pattern in rendicontazione/Bandi usa sempre label esplicita con size='small' outlined.

Ar1AdminConfig — bottoni azioni unificati:
  * tplActiveActionsTpl (Tab Template):
    - 'Anteprima' icon eye + severity info (apre PDF mock in nuova tab)
    - 'Layout' icon pencil (apre dialog edit layout)
    - 'Nuova vers.' icon plus + severity warning
  * pecActionsTpl (Tab Regole):
    - 'Modifica' icon pencil (outlined small)
    - icon trash only + severity danger (outlined small, tooltip 'Elimina')

Allargate colonne: template 170→280px, regole 120→200px per accomodare label italiani.
Build stamp aggiornato nel commento header per forzare rebuild CRA.
2026-04-23 15:06:37 +02:00
BFLOWS
00ef1eb1e0 feat(ar1-admin): bottone Anteprima PDF wirato a BE /admin/ar1-templates/:id/preview-pdf
Rimosso toast 'TODO' dal bottone eye icon in Tab Template. Ora:
  1. chiama Ar1Service.previewTemplatePdf(row.id) (nuovo metodo, ritorna Blob)
  2. crea URL.createObjectURL + window.open('_blank')
  3. revokeObjectURL dopo 60s (cleanup)
  4. toast info iniziale 'Generazione anteprima...' + error toast su fail

Service: +1 metodo previewTemplatePdf(templateId) che torna Promise<Blob>
usando buildHeadersMultipart (nessun Content-Type per risposta binaria).
2026-04-23 14:36:32 +02:00
BFLOWS
4a719ded5b feat(ar1-admin): riscrittura italiana + 5 tab con Testi PEC + editor form-based
Riscrittura completa di Ar1AdminConfig.js (490 -> 888 LOC) con UI italianizzata,
labels parlanti, editor form-based per layout template, tab 'Testi comunicazioni'
con editor dei 5 template email + anteprima server-side.

CAMBIAMENTI FUNZIONALI:

Tab 1 Template:
  - 'In uso' (ACTIVE/DRAFT) vs 'Archiviati' ora in DUE Card separate, non mescolati
  - Nomi varianti in italiano ('A1 — Persona Giuridica (societa, ente)', ecc)
  - Status tag italiano ('In uso', 'Archiviato', 'Bozza')
  - Editor layout: DEFAULT modalita form (Brand/Header/Intro/Privacy con campi
    espliciti nome, logo_url, colori primario+accento, titoli, saluto, corpo
    introduttivo, URL privacy, testo piede). Toggle 'Modalita avanzata (JSON raw)'
    per chi vuole editare tutto il layout_config.
  - Bottone 'Anteprima PDF' presente (placeholder toast TODO — endpoint BE da wirare)
  - Bottone 'Nuova versione' eredita automaticamente layout_config da ACTIVE corrente

Tab 2 Policy:
  - Tutti i label tradotti in italiano con help text inline per ogni campo
  - Dropdown 'Categoria documento aziendale' da GET /admin/document-categories
    (cross-schema read a gepafin_schema.document_category) invece di InputNumber
    raw. Mostra 'DURC — Documento Unico...', 'ANTIRICICLAGGIO — Dichiarazione...'
  - Switch con descrizioni espanse (cosa fa, quando si attiva)
  - Divider visivo tra campi numerici e switch booleani

Tab 3 Regole reminder:
  - Colonna 'Regola' con label italiano parlante + kind tecnico in sottotitolo
  - Colonna 'Quando parte' calcolata dinamicamente: '30 giorni PRIMA',
    'Il giorno della scadenza', '5 giorni DOPO', ecc
  - Colonna 'Ricorrenza' formattata ('una tantum' vs 'ogni 30 giorni')
  - Dialog edit: Dropdown PEC_KIND_OPTIONS con 5 etichette italiane (kind
    disabled se editing esistente), help text inline sul campo offset_days
    che cambia live ('3 giorni prima della scadenza' / 'giorno della scadenza'
    / '3 giorni dopo la scadenza')

Tab 4 Invio massivo:
  - Label italiano 'Solo aziende con AR1 scaduta' / 'Solo aziende senza AR1'
  - Pulsante 'Anteprima (non invia)' con toast descrittivo
  - Pulsante 'Invia PEC' richiede ConfirmDialog
  - Messaggio warning giallo chiarisce che la PEC sara dispatchata dal BE Gepafin
  - Box esito con matched / marked_for_pec / company_ids (trimmato a 30+…)

Tab 5 Testi comunicazioni (NUOVO):
  - Banner info + elenco variabili supportate come Tag cliccabili (7 variabili)
  - DataTable 5 righe: Tipo comunicazione (label IT + kind mono) / Oggetto /
    Versione (Tag 'v1', 'v2', ...) / Aggiornato il / Azione 'Modifica'
  - Dialog edit massimizzabile: subject + body_html (textarea monospace 10
    righe) + body_text fallback (5 righe) + note interne
  - Bottone 'Anteprima (dati di esempio)' chiama POST /admin/ar1-email-templates/
    {kind}/preview e mostra rendering HTML interpolato (dangerouslySetInnerHTML)
    con subject renderizzato + body in box stile email
  - Save bump version lato BE (toast 'Testo aggiornato (version N)')

SERVICE:
  ar1Service.js esteso da 213 -> 247 LOC:
    + listDocumentCategories (GET /admin/document-categories)
    + listEmailTemplates / getEmailTemplate / updateEmailTemplate /
      previewEmailTemplate (4 metodi admin email)

VALIDAZIONE:
  Parse-check @babel/parser plugin JSX: 2/2 OK (service + Ar1AdminConfig).
  Hot-reload CRA webpack compiled with 1 warning (solo unused-vars pre-esistenti).

COSE NON ANCORA FATTE (next):
  - Endpoint BE POST /admin/ar1-templates/:id/preview per anteprima PDF
    (wiring FE: rimuovere toast TODO dentro openEditLayout/tplActiveActionsTpl)
  - Test manuale dal browser con hard-refresh
2026-04-23 14:32:34 +02:00
BFLOWS
2028239759 feat(ar1): superadmin Ar1AdminConfig TabView 4 sezioni (templates+policy+pec+bulk)
Nuova voce sidebar 'Configurazione AR1' (icona pi pi-id-card, href /ar1-admin,
permesso MANAGE_TENDERS) accanto a 'Rendicontazione'. Pagina dedicata
Ar1AdminConfig.js (490 LOC) con TabView PrimeReact a 4 sezioni:

1. TEMPLATE — DataTable con 3 varianti (A1/A2/A3), status+version+quadri_count.
   Bottone 'Edit layout L2' (Dialog con InputTextarea JSON layout_config,
   chiama PUT /admin/ar1-templates/:id/layout-config).
   Bottone 'Nuova versione' (Dialog con version semver + layout + toggle
   activate_now, chiama POST /admin/ar1-templates/:variant/new-version).

2. POLICY — grid 2 colonne con editor singleton:
   - validity_days (InputNumber 30-1825, default 365)
   - popup_dismiss_hours (InputNumber 1-168, default 24)
   - company_document_category_id (InputNumber, default 4 ANTIRICICLAGGIO)
   - popup_force_on_expired (InputSwitch)
   - auto_archive_on_company_document (InputSwitch)
   - allow_bulk_recompilation_request (InputSwitch)
   Save via PUT /admin/ar1-policy.

3. REGOLE REMINDER PEC — DataTable CRUD con Dialog edit:
   kind (disabled se editing), offset_days, is_recurring+recurring_interval_days,
   enabled, description. Chiamate POST/PUT/DELETE /admin/ar1-pec-schedule-config.

4. INVIO MASSIVO PEC — InputText company_ids virgola-separati, Checkbox
   only_expired/only_missing. Bottoni:
   - Dry-run (eye icon, severity info) → chiama con dry_run=true
   - Invia PEC live (send icon, severity warning) → ConfirmDialog prima di
     chiamare con dry_run=false
   Result box con matched/marked counts.

SERVICE — src/modules/ar1/service/ar1Service.js esteso da 164 a 213 LOC:
+ listTemplates (con query params opzionali)
+ getTemplateDetail
+ updateTemplateLayout
+ createNewTemplateVersion
+ getPolicy / updatePolicy
+ listPecSchedule / createPecRule / updatePecRule / deletePecRule
+ bulkRequestRecompilation

INTEGRAZIONE:

src/layouts/DefaultLayout/components/AppSidebar/index.js
  + voce 'Configurazione AR1' id=23 (MANAGE_TENDERS) dopo 'Rendicontazione'

src/routes.js
  + import Ar1AdminConfig
  + route /ar1-admin (solo ROLE_SUPER_ADMIN, altri PageNotFound)

VALIDAZIONE: parse-check 9 file con @babel/parser + plugin JSX: 9 OK / 0 FAIL.
2026-04-23 11:12:39 +02:00
BFLOWS
dbed5963b2 fix(ar1): Ar1Wizard crash su activeQuadro undefined (7 guard)
Bug: TypeError 'Cannot read properties of undefined (reading id)' su Ar1Wizard.js:358
al click Avanti. Causa: activeIndex poteva uscire fuori range quadri.length
(es. dopo re-render con schema_snapshot diverso, o race tra saveQuadro e
setForm+setActiveIndex). Gli onClick/onBlur accedevano a activeQuadro.id
senza controllo null.

Fix:
1. clamp safeIndex = Math.max(0, Math.min(activeIndex, quadri.length - 1))
2. activeQuadro = quadri[safeIndex] (invece di activeIndex diretto)
3. isLastStep usa safeIndex
4. Steps.activeIndex usa safeIndex; onSelect clampa e.index
5. Bottone Indietro: guard 'if (!isReadonly && activeQuadro)' + Math.max(0,...)
6. Bottone Avanti: guard + Math.min(quadri.length-1,...)
7. Card onBlur: guard su activeQuadro
8. submitFinale: return se !activeQuadro, usa activeQuadro invece di quadri[activeIndex]
9. early return se quadri.length === 0 (template senza quadri editabili)

Parse check OK. Webpack compiled 1 warning (vecchio, non nostro).
2026-04-23 11:11:01 +02:00
BFLOWS
7c508e743b feat(ar1-admin): pagina superadmin Configurazione AR1 (pattern Rendicontazione)
Seconda voce sidebar per superadmin, pattern identico a Rendicontazione:
- benef (APPLY_CALLS) -> 'Dichiarazione AR1' -> /ar1 (compilazione)
- superadmin (MANAGE_TENDERS) -> 'Configurazione AR1' -> /ar1-admin (config)

service/ar1Service.js: +11 metodi admin (adminList/Get Templates, adminUpdateLayout,
adminNewVersion, adminGet/Update Policy, CRUD PecSchedule, adminBulkRecompilation).

pages/Ar1AdminConfig.js (532 LOC): 4 tab PrimeReact TabView:
  1. Template AR1: DataTable 3 varianti, badge status ACTIVE/DRAFT/ARCHIVED,
     drawer detail con textarea JSON layout_config editabile + save,
     bottone 'nuova versione' con modale (semver regex + activate_now)
  2. Policy: form con InputNumber/InputSwitch/Checkbox per 6 campi policy
     (validity_days 30-1825, popup_dismiss_hours 1-168, popup_force_on_expired,
     auto_archive_on_company_document, company_document_category_id, allow_bulk)
  3. Regole Reminder PEC: DataTable CRUD con dialog edit, Chips, InputSwitch
  4. Invio Massivo PEC: 4 filtri (only_expired, only_missing, company_ids Chips,
     expired_before Calendar) + dry-run counter + confirm dialog + submit live

Sidebar: voce id=23 'Configurazione AR1' icon 'pi pi-cog' href '/ar1-admin'
permessi MANAGE_TENDERS (accanto a 'Rendicontazione').

Routes: /ar1-admin solo ROLE_SUPER_ADMIN, altri ruoli -> PageNotFound.

Parse check @babel/parser+JSX: 4 OK / 0 FAIL. Webpack compiled 1 warning (vecchio,
unrelated).
2026-04-23 11:06:18 +02:00
BFLOWS
c407bd0b0e fix(ar1): companyId letto da chosenCompanyId (pattern Imieibandi)
Ar1Home usava 'getUser()?.companyId' che non esiste nello store. Il pattern
corretto è useStoreValue('chosenCompanyId') come in src/pages/Imieibandi/index.js
e negli altri moduli beneficiario.
2026-04-23 10:53:37 +02:00
BFLOWS
46ee801bd0 feat(ar1): modulo Dichiarazione AR1 Adeguata Verifica D.Lgs.231/2007
Nuovo modulo FE speculare a rendicontazione. Integrazione con microservizio
ar1-compiler (AX41:18091, 26 endpoint live, JWT HS512 condiviso con GEPAFIN-BE).

FILE CREATI (1159 LOC):

src/modules/ar1/service/ar1Service.js (166 LOC)
  Client HTTP pattern 1:1 da rendicontazioneService.js. Metodi:
  - getStatusForCompany (pubblico, per compliance modal)
  - createDraft / getForm / listFormsForCompany / updateQuadri
  - submitForSignature / deleteForm
  - generatePdf / downloadPdfUnsigned / downloadPdfSigned
  - uploadSignature (multipart) / reVerifySignature
  - archiveToCompanyDocument (manuale, solitamente auto)

src/modules/ar1/components/Ar1StatusTag.js (26 LOC)
  Badge PrimeReact Tag per 9 stati (MISSING/DRAFT/AWAITING_SIGNATURE/SIGNED/
  VERIFIED/VALID/APPROACHING/EXPIRED/SUPERSEDED) con severity+icon specifici.

src/modules/ar1/components/Ar1ComplianceModal.js (137 LOC)
  Dialog al login se azienda ha AR1 MISSING/EXPIRED (bloccante, no dismiss)
  o APPROACHING (dismissable 24h via sessionStorage). CTA 'Compila ora'
  naviga a /ar1. Da montare nel layout principale con <Ar1ComplianceModal
  companyId={userCompanyId} />.

src/modules/ar1/pages/Ar1Home.js (248 LOC)
  Pagina principale beneficiario. Card status con countdown + CTA dinamici
  (Compila/Riprendi/Firma/Rinnova). DataTable storico con azioni per riga
  (riprendi, firma, elimina, scarica firmato). Dialog scelta variante per
  nuovo form (A1/A2/A3).

src/modules/ar1/pages/Ar1Wizard.js (372 LOC)
  Wizard data-driven: legge schema_snapshot del form e genera step/field
  dinamicamente. Un step PrimeReact Steps per ogni quadro. Auto-save onBlur
  via PUT /quadri. 7 renderer type-aware:
    - text/email (uppercase CF regex)
    - textarea
    - date (Calendar it-IT)
    - checkbox
    - radio (opzioni string o {label,value})
    - enum (Dropdown)
    - yes_no_with_note (RadioButton SI/NO + textarea condizionale)
  Handler row_type per Quadro B titolari effettivi (array fino a max_rows).
  Handler upload_slots per Quadro F allegati. Nested_full per Quadro C LR
  e D esecutore con sezione 'Dettaglio aggiuntivo'.
  Solo DRAFT editabile, AWAITING_SIGNATURE+ in sola lettura.
  Submit finale invia PUT /quadri + PUT /submit-for-signature e naviga
  a /ar1/signature/:id.

src/modules/ar1/pages/Ar1Signature.js (210 LOC)
  Pagina firma:
    Step 1: genera PDF + download unsigned (filename AR1_A1_da-firmare.pdf)
    Step 2: FileUpload PDF firmato (.pdf PAdES o .p7m CAdES, 50MB max)
            → DocVerify call (toast 'Verifica in corso, fino a 60s')
            → 4 outcome con toast specifici:
                VERIFIED → success + redirect Home
                SIGNED_NOT_VERIFIED → warn 'verifica manuale'
                SIGNED_DOCVERIFY_UNAVAILABLE → warn 'DocVerify down'
                NO_SIGNATURE_DETECTED → error 'Firmare prima il PDF'
  Card 'Dettagli verifica' con firmatario/CF/metodo/scadenza se VERIFIED.

INTEGRAZIONE (pattern identico a rendicontazione):

src/layouts/DefaultLayout/components/AppSidebar/index.js
  Aggiunta voce sidebar:
    label: 'Dichiarazione AR1', icon: 'pi pi-id-card', href: '/ar1', id: 22,
    enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length

src/routes.js
  Import Ar1Home/Ar1Wizard/Ar1Signature.
  3 route con pattern ruoli:
    /ar1 → BENEFICIARY/SUPER_ADMIN: Ar1Home, altri: PageNotFound
    /ar1/wizard/:formId → BENEFICIARY/SUPER_ADMIN: Ar1Wizard
    /ar1/signature/:formId → BENEFICIARY/SUPER_ADMIN: Ar1Signature

.env
  + REACT_APP_AR1_API_URL=http://78.46.41.91:18091
  + REACT_APP_RENDICONTAZIONE_API_URL=http://78.46.41.91:18090

VALIDAZIONE:
8 file @babel/parser parse-check con plugin JSX: 8 OK / 0 FAIL.

PROSSIMI STEP (non in questo commit):
- Rinaldo integra Ar1ComplianceModal nel layout principale post-login
- Rinaldo deploya DocVerify sul server BFLOWS/Gepafin e configura
  AR1_DOCVERIFY_URL nel microservizio ar1-compiler (senza DocVerify,
  degrada gracefully a SIGNED senza VERIFIED)
- BE Spring Ar1AmendmentPoller (4.5h, bundle in /tmp/rinaldo-bundle-ar1.zip)
2026-04-23 10:36:17 +02:00
BFLOWS
1116f96acf feat(amendment): ROUND 3.B UI benef completa — response upload + render HTML + dialog rich
Completamento lato beneficiario dopo R3.A (IstruttoriaPratica completo).
Modulo rendicontazione speculare al pattern piattaforma FE.

==5 PATCH APPLICATE==

1. submitAmendmentResponse — callback-chain con upload response_document
   Se l'utente ha selezionato un file nel FileUpload, dopo il submit del
   testo chiama uploadResponseDocument. Se l'upload fallisce, il testo
   resta salvato (toast warn). Success unificato via afterMutation.

2. Sezione amendments benef — filtro DRAFT
   Il benef non deve vedere le bozze: le DRAFT vivono solo lato istruttore
   finche non viene chiamato /send. Doppio filtro (count + map).

3. Sezione amendments benef — render HTML + metadata
   Il request_text ora viene da Editor lato istruttore (HTML), quindi
   serve dangerouslySetInnerHTML. Mostra inoltre response_days, badge
   'Allegato istruttore presente' se amendment_document_path, badge
   'Allegato inviato con la risposta' se response_document_path.

4. Dialog risposta — Editor rich text
   Sostituita InputTextarea con Editor (primereact) coerente con il
   pattern del lato istruttore. height=180px.

5. Dialog risposta — FileUpload response_document + visualizzazione allegato istruttore
   - Header del dialog mostra: richiesta HTML, badge 'Istruttore ha
     allegato un documento' se presente, scadenza con icona calendario
     e response_days in testo di aiuto.
   - Nuovo campo FileUpload basic (PDF max 10MB) agganciato a
     amendDialog.response_file.
   - Width dialog aumentato da 560px a 720px (coerente con
     IstruttoriaPratica dialog create/edit).

==VALIDAZIONE==
@babel/parser JSX: 31 nodes, no errori. File 69148 chars.

==STATO COMPLESSIVO SOCCORSO ISTRUTTORIO v3==
Backend (rendicontazione-api):  COMPLETO — da13ca7 R1 + 34c4a47 R2
Frontend (bflows-bandi-fe):     COMPLETO — 4982df4 R3.A + questo commit
Documento integrazione Cecilia: TODO (prossima sessione)

==NEXT==
- Test E2E UI sandbox (crea DRAFT con allegato istruttore -> modifica ->
  invia -> simula mark-pec-sent via SQL -> benef vede soccorso con badge
  allegato -> benef risponde con response_file -> istruttore vede
  response con badge e chiude)
- Scrivere /opt/docs/gepafin-rendicontazione-amendment-spec-per-BE.md
  per Cecilia Moretti con: spec endpoint /internal (pending-pec,
  pending-reminder, mark-pec-sent, mark-pec-failed), poller cron BE,
  tenant routing hub=1 PEC Massiva + ProtocolService 65.108.55.96:8080,
  hub=2 Mailgun. 5 domande aperte (classifica, SviluppUmbria PEC,
  allegati protocollati, ruoli autorizzati, firma digitale response).
2026-04-20 23:20:41 +02:00
BFLOWS
4982df4e60 feat(amendment): ROUND 3 UI istruttore completo + service + benef WIP
Parte FE del soccorso istruttorio v3, allineata al pattern UI della
piattaforma (SoccorsoAddInstructorManager / SoccorsoEditPreInstructor).

==SERVICE (rendicontazioneService.js)==
7 metodi nuovi, pattern fetch identico all'esistente:
  - updateAmendment(practiceId, id, body)          PUT  /{pid}/amendment/{id}
  - deleteAmendment(practiceId, id)                DELETE
  - sendAmendment(practiceId, id)                  POST /send
  - extendAmendment(practiceId, id, days, motiv)   POST /extend
  - sendAmendmentReminder(practiceId, id)          POST /reminder
  - uploadAmendmentDocument(practiceId, id, file)  POST multipart
  - deleteAmendmentDocument(practiceId, id)        DELETE upload-document
  - uploadResponseDocument(practiceId, id, file)   POST multipart

==ISTRUTTORIA (IstruttoriaPratica.js) — COMPLETO==
- AMENDMENT_STATUS esteso con DRAFT
- Import Editor (rich text) + FileUpload
- State amendDialog arricchito: mode create|edit, response_days, internal_note, instructor_file
- State extendDialog nuovo per dialog proroga
- Filter openAmendments include DRAFT (oltre AWAITING, RESPONSE_RECEIVED)
- Pulsante 'Soccorso istruttorio' usa openCreateAmendDialog; tooltip spiega lock
- Funzioni nuove: openCreateAmendDialog, openEditAmendDialog, resetAmendDialog,
  doAmend(sendAfterSave), sendDraftAmendment, deleteDraftAmendment,
  doExtendAmendment, sendReminder. closeAmendment preservato.
- doAmend gestisce anche upload allegato con callback-chain (create/update ->
  uploadAmendmentDocument -> eventualmente sendAmendment).
- Sezione amendments arricchita:
    * pulsanti contestuali per status:
        DRAFT:     Modifica / Invia / Elimina
        AWAITING:  Proroga / Reminder / Chiudi
        RESPONSE_RECEIVED / EXPIRED: Chiudi
    * render HTML richiesta (Editor ha prodotto HTML)
    * mostra: scadenza, response_days, extended_days, creazione, pec_sent_at, protocol_id
    * badge 'Allegato istruttore presente' se amendment_document_path
    * nota interna in box giallo (visibile solo istruttore)
    * risposta benef con eventuale badge 'Allegato risposta presente'
    * errore PEC in box rosso se pec_failed_reason
- Dialog creazione/modifica (720px):
    * Editor rich text per request_text (height 180px)
    * Calendar scadenza con minDate=today
    * InputNumber response_days (default 15, 1-120)
    * InputTextarea internal_note con lucchetto
    * FileUpload mode='basic' PDF max 10MB
    * Due CTA: 'Salva bozza' (submit form) + 'Salva e invia al beneficiario'
- Dialog proroga (480px):
    * Visualizza scadenza attuale
    * InputNumber extended_days (1-60)
    * InputTextarea motivation (registrata in internal_note lato BE)

==BENEFICIARIO (PraticaRendicontazioneEdit.js) — WIP parziale==
Patch applicate (sintatticamente OK, integrazione JSX non finita):
  - Import Editor + FileUpload
  - State amendDialog con response_file + responseFileRef
  - Helper _stripHtmlBenef + validazione su plainText

RIMANE DA FARE (prossima sessione):
  - Chain upload del response_document dentro submitAmendmentResponse
  - Filtrare DRAFT dalla sezione amendments (benef non deve vederle)
  - Render HTML dangerouslySetInnerHTML per request_text
  - Mostrare badge 'Allegato istruttore presente' se amendment_document_path
  - Nel dialog risposta: Editor al posto di InputTextarea + FileUpload response_document

==VALIDAZIONE==
Entrambi i file passati con @babel/parser plugins=jsx,classProperties,
optionalChaining,nullishCoalescingOperator — no sintassi errori.

==NEXT==
R3.bis: completare UI benef (5-10min), testare flusso E2E dalla UI sandbox
(crea DRAFT -> modifica -> invia -> [mark-pec-sent manuale] -> benef risponde con
upload -> istruttore chiude). Scrivere documento integrazione Cecilia Moretti
con spec endpoint /internal che BE Gepafin deve sviluppare (poller cron,
tenant routing multi-hub, PEC Massiva Gepafin + Mailgun SviluppUmbria,
protocol-service 65.108.55.96:8080, shared secret).
2026-04-20 22:55:30 +02:00
BFLOWS
59c254a9c3 feat(docs-picker): dedup per categoria + distinzione admin/azienda + riga doc 3-stati
Dopo test browser ampio con Carlo sono emersi 3 cose importanti:

1. LOGICA: il BE Spring Gepafin non implementa sostituzione automatica
   dei documenti stessa categoria — uploadFileForCompany fa saveAll puro,
   nessun softdelete del precedente, nessun UNIQUE constraint su
   (company_id, document_category_id). In sandbox risultavano 2 DURC
   VALID attivi simultaneamente. Mitigazione picker: dedup client-side
   per categoria, preferenza VALID > DUE, a parita id desc.

2. CATEGORIE: il BE ha 3 macro-tipi (CompanyDocumentTypeEnum):
   COMPANY_DOCUMENT (azienda — DURC/Visura/Bilancio),
   PERSONAL_DOCUMENT (amministratore/legale rappresentante — CI/CF/antiric),
   APPLICATION_DOCUMENT (legato a specifica application).
   Carlo aveva intuito giusto: admin vs azienda e la divisione personal vs
   company. Il picker ora fa 2 chiamate al BE (default retituisce
   COMPANY+APPLICATION, poi filter esplicito documentType=PERSONAL_DOCUMENT)
   e unisce i risultati con dedup.

3. UX RIGA DOCUMENTO: il layout che avevo fatto (FileUploadCell + Tag
   esterno + icon button) rompeva il flex causa nesting, tag e refresh
   andavano a capo. Separati 3 casi semantici puliti:
   - CASO A repository: riga custom con [icona pdf] filename [tag stato]
     [button Cambia] [button Rimuovi] — tutto orizzontale
   - CASO B upload PC: FileUploadCell standard (preview/download/refresh/delete)
   - CASO C vuoto: 2 pulsanti Carica dal PC / Scegli dal repository

CAMBIAMENTI CompanyDocumentPicker.js (+197 -52):
- dedupByCategory(docs) con ranking STATUS+id
- doppia chiamata getCompanyDocuments (default + PERSONAL)
- nuova colonna Origine con badge colorato (blu=Azienda, viola=Admin)
- 3 pulsanti manuali per tab Origine (SelectButton PrimeReact aveva
  issues styling col tema Gepafin: label bianche invisibili sui non-selected)
- ORIGIN_CFG con 3 varianti per COMPANY/PERSONAL/APPLICATION

CAMBIAMENTI PraticaRendicontazioneEdit.js (+55 -17):
- riga doc riscritta con 3 branch distinti per stato
- pulsanti outlined con label esplicite 'Cambia' / 'Rimuovi' (icon-only
  text button avevano scopribilita bassa)
- handler Rimuovi collegato a deleteEntityFile esistente + toast feedback

Test browser verificato con Carlo: dedup ok (2 DURC in DB → 1 nel
picker), tabs Azienda/Amministratore leggibili, label pulsanti chiare,
flusso Cambia/Rimuovi funziona.
2026-04-20 20:30:11 +02:00
BFLOWS
680c25049f style(docs-picker): rimuovi selector righe-per-pagina, paginazione fissa a 10
Su feedback di Carlo: la scelta del page size complica l'UI senza benefit
concreto per il caso d'uso (picker modale dove N doc tipicamente ~20-50).
Paginator ora: FirstPageLink PrevPageLink PageLinks NextPageLink
LastPageLink + counter '1-10 di N'. Righe fisse a 10.
2026-04-20 19:56:13 +02:00
BFLOWS
de8a36b4ab style(docs-picker): fix spacing, icona search, paginazione client-side
Tre fix UX raggruppati sul CompanyDocumentPicker dopo feedback browser di Carlo:

1. Spacing banner informativo → filtri: aumentato a 1.5rem (era classe mb-3
   PrimeReact che non applicava abbastanza spazio)

2. Icona lente ricerca non allineata col placeholder: p-input-icon-left
   in PrimeReact 10 non aggiunge piu padding-left automatico. Sostituito
   lo span wrapper con div position:relative + icona absolute left 1rem
   pointer-events none zIndex 1, InputText paddingLeft 2.75rem. Portable
   tra versioni PrimeReact, funziona indipendentemente da eventuali CSS
   override.

3. Paginazione client-side sulla DataTable: paginator con default 10 righe,
   rowsPerPageOptions [10, 25, 50], template full con navigation + counter
   '1-10 di N'. Rimosso scrollable/scrollHeight 400px (la paginazione
   sostituisce lo scroll infinito, UX piu prevedibile per liste grandi).
   Sufficiente client-side per company con fino a qualche centinaio di
   doc (PMI tipicamente 20-50). Se in futuro servira server-side paginated
   dal BE Spring, e un evoluzione incrementale non bloccante.
2026-04-20 19:54:56 +02:00
BFLOWS
cc829fe25e style(docs-picker): redesign grafico + usa category.categoryName invece di type
Fix identificato testando: il BE Spring CompanyDocumentDao ha 2 filtri che
escludevano i doc seedati: status!=EXPIRED e type IN (COMPANY_DOCUMENT,
APPLICATION_DOCUMENT). Il campo 'type' nel BE e una macro-tipologia,
non il sotto-tipo (DURC/VISURA/etc) che vive in document_category_id.

Seed aggiornato (runtime): type='COMPANY_DOCUMENT' per tutti i doc,
e il sotto-tipo viene esposto via companyDocument.category.categoryName.

Picker rifatto:
- usa category.categoryName come tipo visibile/filtrabile (non piu type)
- filtro status con sole opzioni VALID/DUE (gli EXPIRED sono filtrati
  dal BE e non appaiono mai qui — il gate submit li blocca comunque
  via JOIN live sul microservizio)
- header con icona cartella + contatore X/Y documenti
- banner Message informativo sull esclusione EXPIRED
- filtri in grid CSS rigido (search full-width, 2 dropdown affiancati
  2fr equal) per evitare stacking verticale indesiderato
- DataTable con stripedRows, icone file-pdf, scadenze monospace
- per status DUE: mostra 'scade tra N giorni'
- footer: 'Selezionato: NOME + tag' a sx, pulsanti a dx

Componente passato da 195 a 254 righe. Webpack compila pulito
(solo warning no-unused-vars preesistenti non miei).
2026-04-20 19:46:12 +02:00
BFLOWS
2b6b4dbada feat(docs): picker modal per scegliere documenti dal repository company
Chiude la promessa UX del super_admin (testo editor: 'I documenti già in regola nel
repository della Company saranno riutilizzati automaticamente'). Nel benef, oltre al
classico upload dal PC, e ora possibile pescare documenti dal repository Gepafin
della company, ereditando filename/scadenza e status live (VALID/DUE/EXPIRED).

Nuovi componenti:
- CompanyDocumentPicker.js (195 righe): Dialog PrimeReact con filtri tipo/stato/testo,
  DataTable con radio selection, semaforo tag VALID/DUE/EXPIRED, mostra scadenza
  formattata IT, pulsante conferma disabilitato finche nulla e selezionato.

Servizio:
- RendicontazioneService.linkDocumentFromRepository(remDocId, companyDocId, cb, err)
  chiama il nuovo endpoint microservizio POST .../document/{id}/link-from-repository.

Integrazione PraticaRendicontazioneEdit sezione 4 Documenti:
- 2 state + 2 handler nuovi: repoPicker {visible, docCode}, openRepositoryPicker,
  closeRepositoryPicker, handleRepositoryPick (ensureDocRecord -> link -> toast).
- UI riga documento richiesto ora ha 2 pulsanti quando vuoto:
    [pi-upload] Carica dal PC     [pi-folder-open] Scegli dal repository
- Quando linked: accanto al FileUploadCell compare Tag semaforo con lo status del
  sorgente (VALID=verde/DUE=giallo/EXPIRED=rosso) + pulsante cambia (ri-apre picker).
- CompanyDocumentPicker montato a fondo pagina, riceve practice.company_id +
  currentSourceId per evidenziare la scelta gia fatta.

Webpack compila pulito (solo warning no-unused-vars preesistenti non miei).
Test E2E backend gia verdi nel commit backend 7c8de6a.
2026-04-20 18:55:49 +02:00
BFLOWS
9d23601ba3 style(rendicontazioniMie): redesign dashboard benef con Card/StatTile/TrancheRow
Layout precedente aveva div con inline-style grezzi, totali affastellati
in flex unico, righe tranche plain senza gerarchia visiva.

Redesign completo mantenendo identica la logica:
- Card PrimeReact con header strutturato (titolo bando + azienda/domanda + importo)
- StatTile sub-component (label uppercase 0.75rem + value bold 1.15rem, bordo
  sinistro colorato slate/green/primary/gray)
- Progress bar percentuale cap utilizzato (verde <100%, rosso >=100%)
- TrancheRow sub-component con icona circolare T1/T2 e icone
  pi-file/users/paperclip per metadati
- Tag stato con icona (pencil/send/eye/check-circle/times-circle/exclamation-triangle)
- Divider PrimeReact tra corpo e footer
- Empty state tranches con message informativo su surface-50
- Dialog avvio tranche con box riepilogativo colorato

Il file era stato scritto su disco in sessione precedente ma non committato.
Committo ora prima della chiusura sessione.
2026-04-19 01:01:15 +02:00
BFLOWS
1e40d5e139 fix(istruttoriaPratica): expandedInv array vuoto per DataTable con subheader
Runtime TypeError cliccando ▸ per espandere le note di una fattura:
  '(collection || []).findIndex is not a function'

Causa: la DataTable fatture usa rowGroupMode='subheader' + groupRowsBy.
In questa modalita PrimeReact chiama expandedRows.findIndex(...) al toggle
della row. Il valore iniziale era {} (oggetto) — findIndex non esiste
sull'oggetto → crash alla prima espansione.

Fix: useState([]) invece di useState({}). Il reducer di onRowToggle
gestisce array/oggetto trasparentemente, ma il bootstrap deve essere
un array quando c'e rowGroupMode.

La tabella ULA (expandedUla) resta {} perche non usa subheader e
PrimeReact accetta entrambi i formati in quella configurazione.

Segnalazione Carlo (demo in corso).
2026-04-18 19:36:11 +02:00
BFLOWS
49b7acf987 style(istruttoriaPratica): DataTable fatture width 100% come ULA
La sezione 'Verifica fatture' appariva piu stretta di 'Verifica dipendenti ULA'
in istruttoria. La tabella ULA era wrappata in <div style={width:100%}> mentre
quella fatture no: il componente DataTable di PrimeReact non eredita
sempre la width del parent appPageSection quando e figlio diretto di una
IIFE senza container intermedio.

Fix:
- aggiunto style={{ width: '100%' }} alla prop top-level della DataTable fatture
  (si applica al wrapper .p-datatable, non solo al <table> interno)
- aggiunto wrapper <div style={{ width: '100%' }}> attorno alla DataTable
  fatture, coerente con la struttura gia usata dalla sezione ULA

Nessuna modifica al contenuto o alle colonne.

Segnalazione Carlo.
2026-04-18 19:34:03 +02:00
BFLOWS
6f83574714 fix(IstruttoriaQueue): toggle manager view non compariva mai per bug store
Due bug correlati risolti:

1. userRole era sempre null. Il codice usava storeGet('getUser') che non esiste
   nel selectors (sono solo getToken, getRole, getPermissions). Sostituito con
   storeGet('getRole') che ritorna userData.role.roleType.
   Effetto: canUseManagerView e sempre false, toggle manager mai visibile,
   il capo istruttore vede solo 'Nessuna pratica in coda'.

2. managerMode partiva a false anche per manager e superadmin. Ora default
   true per ROLE_INSTRUCTOR_MANAGER e ROLE_SUPER_ADMIN: partono in vista
   'tutte le pratiche' con possibilita riassegnare.

Rimosso anche import useMemo non piu usato.

Segnalazione Carlo: 'qualcosa non quadra nel profilo del capoistruttore'.
2026-04-18 19:13:01 +02:00
BFLOWS
4cd74cd500 fix(dashboard-benef): allinea RendicontazioniMie al pattern grafico Gepafin
La pagina 'Le mie rendicontazioni' usava div con style inline (border, padding,
background white) e classi inventate/flex wrapper custom che ignoravano il sistema
CSS di Gepafin (appPage, appPageSection, appPageSection__withBorder, __list,
__listItem, __pMeta, __actions) usato ovunque altrove (Dashboard, Bandi, Domande).
Risultato: card piena larghezza schermo, tipografia incoerente, spaziature
al 100% invece di contenute dentro il pattern standard.

Riscritta usando esclusivamente le classi di /assets/scss/components/appPage.scss:
- appPage + appPage__pageHeader (wrapper + header con bordo sinistro colorato)
- appPage__spacer per distacco
- wrapper esterno appPageSection (ora le card sono contenute dentro la sezione)
- ogni application-card e' un appPageSection__withBorder (bordo 1px + radius 6
  + padding 17px gia stilati) con .row interno flex + .rowContent
- metriche cap/approvato/disponibile/tranches usano appPageSection__pMeta
  (2 span, il secondo a sinistra/destra) invece di flex custom
- lista tranches usa ul.appPageSection__list + li.appPageSection__listItem.row
  che ha gia padding 15px + border-bottom gestiti dallo scss
- icone + tag in appPageSection__iconActions
- bottoni nuova-tranche in appPageSection__actions con justify-content:flex-end
- empty state in appPageSection__withBorder centrato
- colori CSS variables (var(--primary-color), var(--text-color-secondary),
  var(--green-700), var(--button-secondary-borderColor)) — niente colori inline

Nessuna modifica alla logica (load, openStartDialog, confirmStart, nav).
Dialog 'Avvia nuova tranche' invariato (gia era OK con appForm__field).
2026-04-18 18:58:44 +02:00
BFLOWS
8988bed952 feat(schemas): picker 3-card blank/template/clone per nuovo schema rendicontazione
Sostituisce il vecchio flusso che proponeva solo 'Inizializza con template RE-START'.
Quando il bando non ha ancora uno schema, mostra un picker a 3 card:

1. 'Nuovo schema' -> inizializzazione blank (scheletro vuoto da popolare)
2. 'Da template'  -> dropdown con template predefiniti + descrizione
3. 'Clona da bando' -> dropdown bandi con schema esistente

Nuovo componente: components/SchemaTemplatePicker.js (200 righe).
Gestisce:
- loading parallelo di templates + clonable-calls
- selezione card con border highlight primary-color
- dropdown espanso solo sulla card attiva (stopPropagation su click)
- bottone Inizia disabilitato finche la selezione non e completa
- spinner durante init, callback onInitialized con schema_json per aggiornare
  il form dell'editor senza reload pagina

Nuovo service esportato: schemaPickerService { listTemplates, listClonableCalls,
initializeSchema(callId, payload) }.

BandoRendicontazioneSchemaEdit.js: rimosso il box 'Inizializza con template RE-START',
sostituito con <SchemaTemplatePicker /> quando !hasSchema. onInitialized popola
setSchemaRecord + setForm + mostra toast di conferma. Funzione handleInitializeRestart
resta nel file (non ancora chiamata, per sicurezza rollback).
2026-04-18 18:51:57 +02:00
BFLOWS
381fd64fef feat(v2): FE multi-tranche + custom_checks + manager view
service/rendicontazioneService.js:
- startPractice(appId, cb, cb, opts) v2: accetta period_label + copy_ula_from_previous
- copyUlaOptions(practiceId): preview ULA tranche N-1 per pre-fill
- Custom checks: listCustomChecks, declareCustomCheck (form-data + optional file),
  deleteCustomCheckDocument, verifyCustomCheck, fetchCustomCheckDocumentBlob
- Manager: managerAssignments, managerInstructorsList, reassignInstructor

B1 BandoRendicontazioneSchemaEdit.js (editor superadmin):
- schemaJsonToForm: estrae gate_rules.max_tranches + custom_checks[] top-level
- formToSchemaJson: scrive max_tranches e custom_checks + schema_version=2
- Helpers addCheck/removeCheck/updateCheck (pattern fieldsRepeater esistente)
- Sezione 7 'Tranches di rendicontazione': InputNumber max_tranches (1-20)
- Sezione 8 'Controlli aggiuntivi': array editable con code (snake_case sanitized),
  label, description, requires_document, required

B2 RendicontazioniMie.js (dashboard benef) — RISCRITTA:
- Raggruppamento per application_id con card per bando
- Riquadro info cumulativo (cap totale, gia approvato, disponibile, tranches N/M)
- Elenco tranche con badge stato + bottoni 'Continua' (DRAFT) / 'Apri' (non editable)
- Bottone '+ Nuova rendicontazione' con 4 stati:
  attivo / disabilitato 'Limite raggiunto' / 'Completa prima' / 'Remissione esaurita'
- Dialog avvio: InputText period_label + Checkbox copy_ula (solo se sequence > 1)

B3 PraticaRendicontazioneEdit.js (beneficiario):
- useMemo customChecksDefs da schema_snapshot.custom_checks
- State customChecks + loadCustomChecks useCallback
- Sezione 5/4 'Controlli aggiuntivi (dichiarazioni)':
  per ogni check checkbox 'Dichiaro', badge Obbligatorio/Opzionale/status,
  upload PDF/JPG/PNG 15MB se requires_document, preview filename+size
- Bordo rosso su check obbligatori non dichiarati

B4 IstruttoriaPratica.js (istruttore):
- State customChecks + loadCustomChecks + ccVerifyDialog
- Sezione 'Verifica controlli aggiuntivi' (dopo Verifica documenti):
  lista con label/codice/badge stato beneficiario/validazione/note istruttore
- Azioni: preview, download, thumbs-up (VALIDO toggle), thumbs-down (NON_VALIDO)
- Dialog motivazione NON_VALIDO con InputTextarea (min 5 char)

B5 IstruttoriaQueue.js (manager):
- Toggle 'Coda standard' vs 'Vista manager (riassegnazioni)' visibile solo per
  ROLE_INSTRUCTOR_MANAGER o ROLE_SUPER_ADMIN
- Tabella manager con colonne: Bando/Pratica/Tranche, Stato, Istruttore domanda,
  Assegnato a (o badge 'Da assegnare' se unassigned), Erogato
- Azione 'Riassegna' (o 'Assegna' se unassigned): apre Dialog con Dropdown
  istruttori (pool pre_instructor + manager) + InputTextarea motivazione
- Opzione 'Metti in coda (nessuno)' nel Dropdown per unassign

Tutti i file validati via @babel/parser JSX.
Webpack compila senza errori (solo warning eslint preesistenti non-B).
2026-04-18 17:53:04 +02:00
19 changed files with 5194 additions and 174 deletions

2
.env
View File

@@ -9,3 +9,5 @@ REACT_APP_FAVICON_FILENAME=gepafin-favicon.ico
REACT_APP_HUB_ID=p4lk3bcx1RStqTaIVVbXs REACT_APP_HUB_ID=p4lk3bcx1RStqTaIVVbXs
REACT_APP_EVALUATION_FLOW_ID=1 REACT_APP_EVALUATION_FLOW_ID=1
REACT_APP_LOCAL_DEVELOPMENT=1 REACT_APP_LOCAL_DEVELOPMENT=1
REACT_APP_AR1_API_URL=http://78.46.41.91:18091
REACT_APP_RENDICONTAZIONE_API_URL=http://78.46.41.91:18090

View File

@@ -41,6 +41,13 @@ const AppSidebar = () => {
id: 21, id: 21,
enable: intersection(permissions, ['MANAGE_TENDERS']).length enable: intersection(permissions, ['MANAGE_TENDERS']).length
}, },
{
label: __('Configurazione AR1', 'gepafin'),
icon: 'pi pi-id-card',
href: '/ar1-admin',
id: 23,
enable: intersection(permissions, ['MANAGE_TENDERS']).length
},
{ {
label: __('Dev: cambia utente', 'gepafin'), label: __('Dev: cambia utente', 'gepafin'),
icon: 'pi pi-user-edit', icon: 'pi pi-user-edit',
@@ -55,6 +62,13 @@ const AppSidebar = () => {
id: 3, id: 3,
enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length
}, },
{
label: __('Dichiarazione AR1', 'gepafin'),
icon: 'pi pi-id-card',
href: '/ar1',
id: 22,
enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length
},
{ {
label: __('Bandi disponibili', 'gepafin'), label: __('Bandi disponibili', 'gepafin'),
icon: 'pi pi-bookmark', icon: 'pi pi-bookmark',

View File

@@ -0,0 +1,148 @@
import React, { useEffect, useState } from 'react';
import { __ } from '@wordpress/i18n';
import { useNavigate } from 'react-router-dom';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { Message } from 'primereact/message';
import Ar1Service from '../service/ar1Service';
import Ar1StatusTag from './Ar1StatusTag';
import { useStoreValue } from '../../../store';
const DISMISS_SESSION_KEY_PREFIX = 'ar1-compliance-dismissed-';
const DISMISS_WINDOW_HOURS = 24;
// AR1 (D.Lgs.231/2007) si applica solo alle aziende beneficiarie.
// Admin / istruttore / direttore NON devono ricevere il popup.
const AR1_POPUP_ALLOWED_ROLES = ['ROLE_BENEFICIARY', 'ROLE_CONFIDI'];
/**
* Dialog AR1 mostrato al login se l'azienda ha AR1 MISSING/EXPIRED/APPROACHING.
* - dismissable=false (EXPIRED/MISSING): bloccante, solo CTA "Compila ora"
* - dismissable=true (APPROACHING): X chiude + salva in sessionStorage 24h
*
* Da montare nel layout principale. Esempio:
* <Ar1ComplianceModal companyId={userCompanyId} />
*/
const Ar1ComplianceModal = ({ companyId }) => {
const navigate = useNavigate();
const role = useStoreValue('getRole');
const [status, setStatus] = useState(null);
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Gate ruolo: solo aziende vedono il popup AR1.
if (!AR1_POPUP_ALLOWED_ROLES.includes(role)) {
setLoading(false);
return;
}
if (!companyId) return;
const dismissKey = DISMISS_SESSION_KEY_PREFIX + companyId;
const dismissed = sessionStorage.getItem(dismissKey);
if (dismissed) {
const dismissedAt = parseInt(dismissed, 10);
if (Date.now() - dismissedAt < DISMISS_WINDOW_HOURS * 3600 * 1000) {
setLoading(false);
return;
}
}
Ar1Service.getStatusForCompany(companyId,
(resp) => {
setLoading(false);
const showFor = ['MISSING', 'EXPIRED', 'APPROACHING'];
if (resp && showFor.includes(resp.status)) {
setStatus(resp);
setVisible(true);
}
},
(err) => {
setLoading(false);
console.warn('Ar1ComplianceModal: status check failed', err);
}
);
}, [companyId, role]);
const handleDismiss = () => {
if (!status?.is_popup_dismissible) return;
sessionStorage.setItem(DISMISS_SESSION_KEY_PREFIX + companyId, Date.now().toString());
setVisible(false);
};
const goToCompile = () => {
setVisible(false);
navigate('/ar1');
};
if (loading || !status) return null;
const canDismiss = status.is_popup_dismissible;
const isUrgent = status.status === 'EXPIRED' || status.status === 'MISSING';
return (
<Dialog
header={<div><i className="pi pi-id-card" style={{ marginRight: 8 }} />{__('Dichiarazione AR1 — Adeguata Verifica', 'gepafin')}</div>}
visible={visible}
modal
closable={canDismiss}
closeOnEscape={canDismiss}
dismissableMask={canDismiss}
onHide={handleDismiss}
style={{ width: '560px', maxWidth: '95vw' }}
>
<div>
<div style={{ marginBottom: 12 }}>
<Ar1StatusTag status={status.status} />
</div>
{isUrgent && (
<Message
severity="error"
text={status.must_recompile_reason ||
__('Per proseguire nell\'operativita con Gepafin e necessario aggiornare la dichiarazione di adeguata verifica (D.Lgs. 231/2007).', 'gepafin')}
style={{ marginBottom: 12 }}
/>
)}
{!isUrgent && (
<Message
severity="warn"
text={__('La tua dichiarazione AR1 sta per scadere. Ti chiediamo di aggiornarla per tempo.', 'gepafin')}
style={{ marginBottom: 12 }}
/>
)}
<p style={{ margin: '10px 0', color: '#444' }}>
{__('Il modulo AR1 (Aggiornamento Adeguata Verifica) e richiesto dalla normativa antiriciclaggio D.Lgs. 231/2007. La compilazione si svolge via wizard guidato e termina con la firma digitale (FEQ) del modulo.', 'gepafin')}
</p>
{status.days_to_expiry !== null && status.days_to_expiry !== undefined && (
<p style={{ margin: '10px 0', fontWeight: 600 }}>
{status.days_to_expiry < 0
? __(`Scaduta da ${Math.abs(status.days_to_expiry)} giorni`, 'gepafin')
: __(`Scadenza tra ${status.days_to_expiry} giorni`, 'gepafin')}
</p>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16 }}>
{canDismiss && (
<Button
label={__('Ricordamelo piu tardi', 'gepafin')}
severity="secondary"
outlined
onClick={handleDismiss}
/>
)}
<Button
label={status.status === 'DRAFT' ? __('Riprendi compilazione', 'gepafin') : __('Compila ora', 'gepafin')}
icon="pi pi-arrow-right"
iconPos="right"
onClick={goToCompile}
/>
</div>
</div>
</Dialog>
);
};
export default Ar1ComplianceModal;

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { __ } from '@wordpress/i18n';
import { Tag } from 'primereact/tag';
/**
* Badge per lo status AR1. Stati possibili:
* MISSING, DRAFT, AWAITING_SIGNATURE, SIGNED, VERIFIED, VALID, APPROACHING, EXPIRED, SUPERSEDED
*/
const STATUS_CONFIG = {
MISSING: { severity: 'danger', label: 'Da compilare', icon: 'pi pi-exclamation-circle' },
DRAFT: { severity: 'warning', label: 'Bozza in corso', icon: 'pi pi-pencil' },
AWAITING_SIGNATURE: { severity: 'info', label: 'Attesa firma', icon: 'pi pi-hourglass' },
SIGNED: { severity: 'info', label: 'Firmato', icon: 'pi pi-verified' },
VERIFIED: { severity: 'success', label: 'Verificato', icon: 'pi pi-check-circle' },
VALID: { severity: 'success', label: 'Valido', icon: 'pi pi-check-circle' },
APPROACHING: { severity: 'warning', label: 'In scadenza', icon: 'pi pi-clock' },
EXPIRED: { severity: 'danger', label: 'Scaduto', icon: 'pi pi-times-circle' },
SUPERSEDED: { severity: 'secondary', label: 'Sostituito', icon: 'pi pi-history' },
};
const Ar1StatusTag = ({ status }) => {
const cfg = STATUS_CONFIG[status] || { severity: 'secondary', label: status || '—', icon: 'pi pi-circle' };
return <Tag severity={cfg.severity} icon={cfg.icon} value={__(cfg.label, 'gepafin')} />;
};
export default Ar1StatusTag;

View File

@@ -0,0 +1,442 @@
import React, { useState } from 'react';
// prime
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { InputText } from 'primereact/inputtext';
import { InputTextarea } from 'primereact/inputtextarea';
import { Dropdown } from 'primereact/dropdown';
import { InputNumber } from 'primereact/inputnumber';
import { Checkbox } from 'primereact/checkbox';
import { Tag } from 'primereact/tag';
import { Divider } from 'primereact/divider';
import { Message } from 'primereact/message';
import { Accordion, AccordionTab } from 'primereact/accordion';
import { confirmDialog } from 'primereact/confirmdialog';
const FIELD_TYPE_OPTIONS = [
{ label: 'Testo libero', value: 'text' },
{ label: 'Email', value: 'email' },
{ label: 'Data', value: 'date' },
{ label: 'Checkbox (si/no)', value: 'checkbox' },
{ label: 'Scelta singola (radio)', value: 'radio' },
{ label: 'Menu a tendina (enum)', value: 'enum' },
{ label: 'Si/No con nota', value: 'yes_no_with_note' }
];
/**
* Editor della struttura questions_snapshot di un template AR1.
*
* Props:
* value: { quadri: [...], variant, legal_ref, ... }
* onChange: (newValue) => void
*
* Permette:
* - Modificare metadati snapshot (variant_label, variant_description, legal_ref, normative_frame)
* - Modificare per ogni quadro: id, title, description
* - Aggiungere/rimuovere/modificare fields con tipo, label, required, pattern, max_length, options, prefill_from
* - Per Quadro G (is_legal_frame=true): warning visuale + editor del description
* - Per Quadri B (row_type=titolare): edit row_fields separato
* - Per Quadri C/D (nested_full): edit fields nested
* - Aggiungere/rimuovere quadri interi
*/
const QuadriStructureEditor = ({ value, onChange }) => {
const qs = value || { quadri: [] };
const quadri = qs.quadri || [];
const update = (newQs) => onChange(newQs);
// --- metadati snapshot ---
const updateMeta = (key, v) => update({ ...qs, [key]: v });
// --- quadri ---
const updateQuadro = (idx, partial) => {
const newQuadri = quadri.map((q, i) => i === idx ? { ...q, ...partial } : q);
update({ ...qs, quadri: newQuadri });
};
const moveQuadro = (idx, direction) => {
const newIdx = idx + direction;
if (newIdx < 0 || newIdx >= quadri.length) return;
const newQuadri = [...quadri];
[newQuadri[idx], newQuadri[newIdx]] = [newQuadri[newIdx], newQuadri[idx]];
update({ ...qs, quadri: newQuadri });
};
const removeQuadro = (idx) => {
const q = quadri[idx];
confirmDialog({
message: `Eliminare il quadro "${q.id} - ${q.title}"? Sara creata una nuova versione.`,
header: 'Conferma eliminazione quadro',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Elimina',
rejectLabel: 'Annulla',
acceptClassName: 'p-button-danger',
accept: () => {
const newQuadri = quadri.filter((_, i) => i !== idx);
update({ ...qs, quadri: newQuadri });
}
});
};
const addQuadro = () => {
const nextLetter = String.fromCharCode(65 + quadri.length); // A, B, C...
const newQuadro = {
id: nextLetter,
title: `Quadro ${nextLetter} - Nuovo quadro`,
description: '',
fields: []
};
update({ ...qs, quadri: [...quadri, newQuadro] });
};
// --- fields dentro quadro ---
const updateField = (qIdx, fIdx, partial) => {
const q = quadri[qIdx];
const newFields = (q.fields || []).map((f, i) => i === fIdx ? { ...f, ...partial } : f);
updateQuadro(qIdx, { fields: newFields });
};
const addField = (qIdx) => {
const q = quadri[qIdx];
const fields = q.fields || [];
// id basato su letter quadro + numero crescente per evitare collisioni
const newField = {
id: `campo_${q.id || 'X'}_${fields.length + 1}`.toLowerCase(),
type: 'text',
label: 'Nuovo campo',
required: false
};
updateQuadro(qIdx, { fields: [...fields, newField] });
};
const removeField = (qIdx, fIdx) => {
const q = quadri[qIdx];
const newFields = (q.fields || []).filter((_, i) => i !== fIdx);
updateQuadro(qIdx, { fields: newFields });
};
const moveField = (qIdx, fIdx, direction) => {
const q = quadri[qIdx];
const fields = q.fields || [];
const newIdx = fIdx + direction;
if (newIdx < 0 || newIdx >= fields.length) return;
const newFields = [...fields];
[newFields[fIdx], newFields[newIdx]] = [newFields[newIdx], newFields[fIdx]];
updateQuadro(qIdx, { fields: newFields });
};
// --- row_fields (es. Quadro B titolari) ---
const updateRowField = (qIdx, fIdx, partial) => {
const q = quadri[qIdx];
const newRowFields = (q.row_fields || []).map((f, i) => i === fIdx ? { ...f, ...partial } : f);
updateQuadro(qIdx, { row_fields: newRowFields });
};
const addRowField = (qIdx) => {
const q = quadri[qIdx];
const rowFields = q.row_fields || [];
updateQuadro(qIdx, {
row_fields: [...rowFields, { id: `campo_${rowFields.length + 1}`, type: 'text', label: 'Nuovo campo', required: false }]
});
};
const removeRowField = (qIdx, fIdx) => {
const q = quadri[qIdx];
updateQuadro(qIdx, { row_fields: (q.row_fields || []).filter((_, i) => i !== fIdx) });
};
// --- nested fields (es. Quadro C/D) ---
const updateNestedField = (qIdx, fIdx, partial) => {
const q = quadri[qIdx];
const nested = q.nested_full || {};
const newNestedFields = (nested.fields || []).map((f, i) => i === fIdx ? { ...f, ...partial } : f);
updateQuadro(qIdx, { nested_full: { ...nested, fields: newNestedFields } });
};
const addNestedField = (qIdx) => {
const q = quadri[qIdx];
const nested = q.nested_full || { fields: [] };
const fields = nested.fields || [];
updateQuadro(qIdx, {
nested_full: {
...nested,
fields: [...fields, { id: `campo_${fields.length + 1}`, type: 'text', label: 'Nuovo campo', required: false }]
}
});
};
const removeNestedField = (qIdx, fIdx) => {
const q = quadri[qIdx];
const nested = q.nested_full || {};
updateQuadro(qIdx, {
nested_full: { ...nested, fields: (nested.fields || []).filter((_, i) => i !== fIdx) }
});
};
// ----- render field editor (atom) -----
const renderFieldRow = (field, idx, onUpdate, onRemove, onMoveUp, onMoveDown, total) => {
const isEnum = field.type === 'enum' || field.type === 'radio';
return (
<div key={idx} style={{
border: '1px solid #ddd', borderRadius: 4, padding: 10,
marginBottom: 8, background: '#fafafa'
}}>
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr 160px auto', gap: 8, alignItems: 'end' }}>
<div>
<label style={{ fontSize: 11, color: '#555' }}>ID (kebab, no spazi)</label>
<InputText value={field.id || ''}
onChange={(e) => onUpdate({ id: e.target.value.replace(/\s+/g, '_') })}
style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }} />
</div>
<div>
<label style={{ fontSize: 11, color: '#555' }}>Label visualizzato</label>
<InputText value={field.label || ''}
onChange={(e) => onUpdate({ label: e.target.value })}
style={{ width: '100%' }} />
</div>
<div>
<label style={{ fontSize: 11, color: '#555' }}>Tipo</label>
<Dropdown value={field.type || 'text'} options={FIELD_TYPE_OPTIONS}
onChange={(e) => onUpdate({ type: e.value })}
style={{ width: '100%' }} />
</div>
<div style={{ display: 'flex', gap: 4 }}>
<Button icon="pi pi-arrow-up" rounded outlined size="small"
disabled={idx === 0} onClick={() => onMoveUp()} tooltip="Sposta su" />
<Button icon="pi pi-arrow-down" rounded outlined size="small"
disabled={idx === total - 1} onClick={() => onMoveDown && onMoveDown()} tooltip="Sposta giu" />
<Button icon="pi pi-trash" rounded outlined size="small" severity="danger"
onClick={() => onRemove()} tooltip="Elimina campo" />
</div>
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Checkbox inputId={`req_${idx}_${field.id}`} checked={!!field.required}
onChange={(e) => onUpdate({ required: e.checked })} />
<label htmlFor={`req_${idx}_${field.id}`} style={{ fontSize: 12 }}>Obbligatorio</label>
</div>
{field.type === 'text' && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<label style={{ fontSize: 11, color: '#555' }}>Max lunghezza:</label>
<InputNumber value={field.max_length || null}
onValueChange={(e) => onUpdate({ max_length: e.value })}
style={{ width: 90 }} inputStyle={{ fontSize: 12 }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<label style={{ fontSize: 11, color: '#555' }}>Pattern regex:</label>
<InputText value={field.pattern || ''}
onChange={(e) => onUpdate({ pattern: e.target.value || undefined })}
style={{ width: 180, fontFamily: 'monospace', fontSize: 11 }}
placeholder="es. ^[0-9]{11}$" />
</div>
</>
)}
{field.prefill_from && (
<Tag severity="info" value={`prefill: ${field.prefill_from}`} style={{ fontSize: 11 }} />
)}
</div>
{isEnum && (
<div style={{ marginTop: 8 }}>
<label style={{ fontSize: 11, color: '#555', display: 'block', marginBottom: 2 }}>
Opzioni (una per riga)
</label>
<InputTextarea rows={3}
value={(field.options || []).map(o => typeof o === 'string' ? o : (o.label || o.value || '')).join('\n')}
onChange={(e) => {
const opts = e.target.value.split('\n').filter(Boolean);
onUpdate({ options: opts });
}}
style={{ width: '100%', fontSize: 12 }} />
</div>
)}
</div>
);
};
// ----- render -----
return (
<div>
<Message severity="warn" style={{ marginBottom: 14 }}
text="Attenzione: modifiche alla struttura dei quadri generano automaticamente una nuova versione del template. I form gia compilati continuano a usare la versione precedente grazie allo snapshot." />
{/* Metadati */}
<Card title="Metadati template" style={{ marginBottom: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div>
<label style={{ fontSize: 11, color: '#555' }}>Etichetta variante</label>
<InputText value={qs.variant_label || ''}
onChange={(e) => updateMeta('variant_label', e.target.value)} style={{ width: '100%' }} />
</div>
<div>
<label style={{ fontSize: 11, color: '#555' }}>Riferimento normativo</label>
<InputText value={qs.legal_ref || ''}
onChange={(e) => updateMeta('legal_ref', e.target.value)} style={{ width: '100%' }}
placeholder="es. D.Lgs. 231/2007" />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label style={{ fontSize: 11, color: '#555' }}>Descrizione variante</label>
<InputTextarea rows={2} value={qs.variant_description || ''}
onChange={(e) => updateMeta('variant_description', e.target.value)} style={{ width: '100%' }} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label style={{ fontSize: 11, color: '#555' }}>Cornice normativa (testo completo)</label>
<InputTextarea rows={3} value={qs.normative_frame || ''}
onChange={(e) => updateMeta('normative_frame', e.target.value)}
style={{ width: '100%', fontSize: 12 }} />
</div>
</div>
</Card>
{/* Quadri */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h3 style={{ margin: 0 }}>Quadri ({quadri.length})</h3>
<Button label="Aggiungi quadro" icon="pi pi-plus" size="small" outlined onClick={addQuadro} />
</div>
<Accordion multiple>
{quadri.map((q, qIdx) => (
<AccordionTab key={qIdx}
header={
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<Tag value={q.id || '?'} severity={q.is_legal_frame ? 'warning' : 'info'} />
<strong style={{ flex: 1 }}>{q.title || '(senza titolo)'}</strong>
{q.is_legal_frame && <Tag value="NORMATIVO" severity="warning" />}
{q.row_type && <Tag value={`ripetuto (${q.row_type})`} severity="secondary" />}
{q.nested_full && <Tag value="annidato" severity="secondary" />}
<small style={{ color: '#888' }}>
{(q.fields || []).length} campi
{q.row_fields ? ` +${q.row_fields.length} riga` : ''}
{q.nested_full?.fields ? ` +${q.nested_full.fields.length} nidif.` : ''}
</small>
</div>
}>
<div style={{ display: 'grid', gridTemplateColumns: '140px 1fr 1fr auto', gap: 8, alignItems: 'end', marginBottom: 10 }}>
<div>
<label style={{ fontSize: 11, color: '#555' }}>ID quadro</label>
<InputText value={q.id || ''}
onChange={(e) => updateQuadro(qIdx, { id: e.target.value.toUpperCase() })}
style={{ width: '100%', fontFamily: 'monospace' }} maxLength={4} />
</div>
<div style={{ gridColumn: 'span 2' }}>
<label style={{ fontSize: 11, color: '#555' }}>Titolo</label>
<InputText value={q.title || ''}
onChange={(e) => updateQuadro(qIdx, { title: e.target.value })}
style={{ width: '100%' }} />
</div>
<div style={{ display: 'flex', gap: 4 }}>
<Button icon="pi pi-arrow-up" rounded outlined size="small"
disabled={qIdx === 0} onClick={() => moveQuadro(qIdx, -1)} tooltip="Su" />
<Button icon="pi pi-arrow-down" rounded outlined size="small"
disabled={qIdx === quadri.length - 1} onClick={() => moveQuadro(qIdx, 1)} tooltip="Giu" />
<Button icon="pi pi-trash" rounded outlined size="small" severity="danger"
onClick={() => removeQuadro(qIdx)} tooltip="Elimina quadro" />
</div>
</div>
<div style={{ marginBottom: 10 }}>
<label style={{ fontSize: 11, color: '#555' }}>Descrizione</label>
<InputTextarea rows={2} value={q.description || ''}
onChange={(e) => updateQuadro(qIdx, { description: e.target.value })}
style={{ width: '100%' }} />
</div>
{q.is_legal_frame && (
<Message severity="error" style={{ marginBottom: 10 }}
text="Quadro normativo (D.Lgs. 231/2007). Il testo dichiara responsabilita legali del firmatario. Modifica con massima cautela: il contenuto viene inserito nel PDF firmato dal beneficiario." />
)}
{/* Fields normali — sempre mostrato, anche se q.fields non esiste (Quadri B/F/G) */}
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<strong style={{ fontSize: 13 }}>Campi ({(q.fields || []).length})</strong>
<Button label="Aggiungi campo" icon="pi pi-plus" size="small" outlined
onClick={() => addField(qIdx)} />
</div>
{(q.fields || []).map((f, fIdx) =>
renderFieldRow(f, fIdx,
(p) => updateField(qIdx, fIdx, p),
() => removeField(qIdx, fIdx),
() => moveField(qIdx, fIdx, -1),
() => moveField(qIdx, fIdx, 1),
(q.fields || []).length
)
)}
{(!q.fields || q.fields.length === 0) && (
<p style={{ color: '#888', fontStyle: 'italic', textAlign: 'center', fontSize: 12 }}>
Nessun campo diretto. Aggiungine uno col bottone qui sopra
{q.row_type ? ` (o usa i "Campi riga" qui sotto per i ${q.row_type})` : ''}
{q.nested_full ? ' (o usa i "Campi annidati" qui sotto)' : ''}
.
</p>
)}
</>
{/* Row fields (Quadro B titolari) */}
{q.row_type && (
<>
<Divider />
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<strong style={{ fontSize: 13 }}>Campi riga (per ogni {q.row_type}): ({(q.row_fields || []).length})</strong>
<Button label="Aggiungi campo riga" icon="pi pi-plus" size="small" outlined
onClick={() => addRowField(qIdx)} />
</div>
<Message severity="info" style={{ marginBottom: 8, fontSize: 12 }}
text={`Questi campi si ripetono per ogni ${q.row_type} aggiunto dal beneficiario. Non eliminare il row_type se ci sono form gia compilati.`} />
{(q.row_fields || []).map((f, fIdx) =>
renderFieldRow(f, fIdx,
(p) => updateRowField(qIdx, fIdx, p),
() => removeRowField(qIdx, fIdx),
() => {}, () => {},
(q.row_fields || []).length
)
)}
</>
)}
{/* Nested full (Quadro C/D rappresentante/esecutore) */}
{q.nested_full && (
<>
<Divider />
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<strong style={{ fontSize: 13 }}>Campi annidati: ({(q.nested_full.fields || []).length})</strong>
<Button label="Aggiungi campo annidato" icon="pi pi-plus" size="small" outlined
onClick={() => addNestedField(qIdx)} />
</div>
{(q.nested_full.fields || []).map((f, fIdx) =>
renderFieldRow(f, fIdx,
(p) => updateNestedField(qIdx, fIdx, p),
() => removeNestedField(qIdx, fIdx),
() => {}, () => {},
(q.nested_full.fields || []).length
)
)}
</>
)}
{/* Upload slots info (Quadro F) */}
{q.upload_slots && (
<>
<Divider />
<strong style={{ fontSize: 13 }}>Slot upload:</strong>
<div style={{ marginTop: 4 }}>
{q.upload_slots.map((slot, i) => (
<Tag key={i} value={slot.label || slot.id} severity="secondary" style={{ marginRight: 4 }} />
))}
</div>
<small style={{ color: '#888' }}>Gli slot upload sono gestiti dal modello: modifica avanzata non ancora via UI.</small>
</>
)}
</AccordionTab>
))}
</Accordion>
</div>
);
};
export default QuadriStructureEditor;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,247 @@
import React, { useEffect, useRef, useState } from 'react';
import { __ } from '@wordpress/i18n';
import { useNavigate } from 'react-router-dom';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog';
import { Dropdown } from 'primereact/dropdown';
import { Dialog } from 'primereact/dialog';
import { useStoreValue } from '../../../store';
import Ar1Service from '../service/ar1Service';
import Ar1StatusTag from '../components/Ar1StatusTag';
const VARIANT_OPTIONS = [
{ label: 'A1 — Persona Giuridica (societa, ente)', value: 'A1' },
{ label: 'A2 — Ditta Individuale (P.IVA persona fisica)', value: 'A2' },
{ label: 'A3 — Persona Fisica (senza P.IVA)', value: 'A3' },
];
/**
* Ar1Home: schermata principale modulo AR1 per il beneficiario.
* - Card status con countdown
* - CTA dinamici (Compila / Riprendi / Firma / Rinnova)
* - Storico dichiarazioni
*/
const Ar1Home = () => {
const navigate = useNavigate();
const toast = useRef(null);
const companyId = useStoreValue('chosenCompanyId');
const [status, setStatus] = useState(null);
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
const [variantDialogOpen, setVariantDialogOpen] = useState(false);
const [selectedVariant, setSelectedVariant] = useState('A1');
const [creating, setCreating] = useState(false);
const loadAll = () => {
if (!companyId) return;
setLoading(true);
Ar1Service.getStatusForCompany(companyId,
(resp) => setStatus(resp),
(err) => console.warn('getStatus failed', err)
);
Ar1Service.listFormsForCompany(companyId,
(resp) => {
setHistory(resp?.items || []);
setLoading(false);
},
(err) => {
setLoading(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Impossibile caricare lo storico' });
}
);
};
useEffect(() => {
loadAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [companyId]);
const startNewDraft = () => {
setCreating(true);
Ar1Service.createDraft(companyId, selectedVariant,
(resp) => {
setCreating(false);
setVariantDialogOpen(false);
if (resp?.id) navigate(`/ar1/wizard/${resp.id}`);
},
(err) => {
setCreating(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Impossibile creare il form' });
}
);
};
const resumeForm = (formId) => navigate(`/ar1/wizard/${formId}`);
const goToSignature = (formId) => navigate(`/ar1/signature/${formId}`);
const deleteDraft = (formId) => {
confirmDialog({
message: __('Sei sicuro di voler eliminare questa bozza? L\'operazione non puo essere annullata.', 'gepafin'),
header: __('Conferma eliminazione', 'gepafin'),
icon: 'pi pi-exclamation-triangle',
acceptLabel: __('Elimina', 'gepafin'),
rejectLabel: __('Annulla', 'gepafin'),
acceptClassName: 'p-button-danger',
accept: () => {
Ar1Service.deleteForm(formId,
() => {
if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'Bozza eliminata' });
loadAll();
},
(err) => {
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Eliminazione fallita' });
}
);
}
});
};
const renderStatusCard = () => {
if (!status) return null;
const isUrgent = ['MISSING', 'EXPIRED'].includes(status.status);
const canCompile = ['MISSING', 'EXPIRED', 'APPROACHING', 'VALID'].includes(status.status);
const hasActive = status.form_id && ['DRAFT', 'AWAITING_SIGNATURE'].includes(status.status);
return (
<Card title={__('Stato Dichiarazione AR1 — Adeguata Verifica', 'gepafin')} style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<Ar1StatusTag status={status.status} />
{status.variant && (
<span style={{ color: '#666' }}>
{__('Variante:', 'gepafin')} <strong>{status.variant}</strong>
</span>
)}
{status.days_to_expiry !== null && status.days_to_expiry !== undefined && (
<span style={{ color: isUrgent ? '#b71c1c' : '#444', fontWeight: 600 }}>
{status.days_to_expiry < 0
? __(`Scaduta da ${Math.abs(status.days_to_expiry)} giorni`, 'gepafin')
: __(`Scade tra ${status.days_to_expiry} giorni`, 'gepafin')}
</span>
)}
</div>
{status.must_recompile_reason && (
<div style={{ marginTop: 12, padding: 10, background: '#fff3e0', borderLeft: '3px solid #e65100' }}>
<i className="pi pi-info-circle" style={{ marginRight: 6 }} />
{status.must_recompile_reason}
</div>
)}
<div style={{ marginTop: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{hasActive && status.status === 'DRAFT' && (
<Button label={__('Riprendi compilazione', 'gepafin')} icon="pi pi-pencil" onClick={() => resumeForm(status.form_id)} />
)}
{hasActive && status.status === 'AWAITING_SIGNATURE' && (
<Button label={__('Procedi alla firma', 'gepafin')} icon="pi pi-verified" severity="warning" onClick={() => goToSignature(status.form_id)} />
)}
{!hasActive && canCompile && (
<Button
label={status.status === 'MISSING' ? __('Compila adesso', 'gepafin') : __('Rinnova dichiarazione', 'gepafin')}
icon="pi pi-plus"
onClick={() => setVariantDialogOpen(true)}
/>
)}
</div>
</Card>
);
};
const statusTpl = (row) => <Ar1StatusTag status={row.status} />;
const dateTpl = (row, field) => {
const v = row[field];
if (!v) return '—';
try { return new Date(v).toLocaleDateString('it-IT'); } catch (e) { return v; }
};
const actionsTpl = (row) => (
<div style={{ display: 'flex', gap: 4 }}>
{row.status === 'DRAFT' && (
<>
<Button icon="pi pi-pencil" rounded text onClick={() => resumeForm(row.id)} tooltip={__('Riprendi', 'gepafin')} />
<Button icon="pi pi-trash" rounded text severity="danger" onClick={() => deleteDraft(row.id)} tooltip={__('Elimina', 'gepafin')} />
</>
)}
{row.status === 'AWAITING_SIGNATURE' && (
<Button icon="pi pi-verified" rounded text severity="warning" onClick={() => goToSignature(row.id)} tooltip={__('Firma', 'gepafin')} />
)}
{['SIGNED', 'VERIFIED', 'EXPIRED'].includes(row.status) && (
<Button icon="pi pi-download" rounded text onClick={async () => {
try {
const blob = await Ar1Service.downloadPdfSigned(row.id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `AR1_${row.variant}_signed.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: e.message });
}
}} tooltip={__('Scarica firmato', 'gepafin')} />
)}
</div>
);
return (
<div style={{ padding: 16 }}>
<Toast ref={toast} />
<ConfirmDialog />
<h1>{__('Dichiarazione AR1 — Adeguata Verifica', 'gepafin')}</h1>
<p style={{ color: '#666' }}>
{__('Modulo di aggiornamento dell\'adeguata verifica ai sensi del D.Lgs. 231/2007 (normativa antiriciclaggio).', 'gepafin')}
</p>
{renderStatusCard()}
<Card title={__('Storico dichiarazioni', 'gepafin')}>
<DataTable
value={history}
loading={loading}
emptyMessage={__('Nessuna dichiarazione presente', 'gepafin')}
paginator={history.length > 10}
rows={10}
>
<Column field="variant" header={__('Variante', 'gepafin')} />
<Column field="template_version" header={__('Versione modulo', 'gepafin')} />
<Column field="status" header={__('Stato', 'gepafin')} body={statusTpl} />
<Column field="created_at" header={__('Creato il', 'gepafin')} body={(r) => dateTpl(r, 'created_at')} />
<Column field="signed_at" header={__('Firmato il', 'gepafin')} body={(r) => dateTpl(r, 'signed_at')} />
<Column field="expires_at" header={__('Scade il', 'gepafin')} body={(r) => dateTpl(r, 'expires_at')} />
<Column header={__('Azioni', 'gepafin')} body={actionsTpl} style={{ width: 160 }} />
</DataTable>
</Card>
<Dialog
header={__('Scegli tipologia', 'gepafin')}
visible={variantDialogOpen}
onHide={() => setVariantDialogOpen(false)}
style={{ width: '480px', maxWidth: '95vw' }}
modal
>
<p style={{ marginBottom: 10 }}>
{__('Seleziona la tipologia di soggetto che rappresenti:', 'gepafin')}
</p>
<Dropdown
value={selectedVariant}
options={VARIANT_OPTIONS}
onChange={(e) => setSelectedVariant(e.value)}
style={{ width: '100%', marginBottom: 16 }}
/>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label={__('Annulla', 'gepafin')} severity="secondary" outlined onClick={() => setVariantDialogOpen(false)} />
<Button label={__('Inizia compilazione', 'gepafin')} icon="pi pi-arrow-right" iconPos="right" loading={creating} onClick={startNewDraft} />
</div>
</Dialog>
</div>
);
};
export default Ar1Home;

View File

@@ -0,0 +1,222 @@
import React, { useEffect, useRef, useState } from 'react';
import { __ } from '@wordpress/i18n';
import { useNavigate, useParams } from 'react-router-dom';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { FileUpload } from 'primereact/fileupload';
import { Message } from 'primereact/message';
import { ProgressSpinner } from 'primereact/progressspinner';
import Ar1Service from '../service/ar1Service';
import Ar1StatusTag from '../components/Ar1StatusTag';
/**
* Pagina firma AR1.
* URL: /ar1/signature/:formId
*
* Flusso: genera PDF → download unsigned → firma FEQ client side → upload signed → DocVerify.
*/
const Ar1Signature = () => {
const { formId } = useParams();
const navigate = useNavigate();
const toast = useRef(null);
const [form, setForm] = useState(null);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadAttempt, setUploadAttempt] = useState(0);
const fileUploadRef = useRef(null);
const resetUploadInput = () => {
try { fileUploadRef.current?.clear?.(); } catch (_) {}
setUploadAttempt(n => n + 1);
};
const refreshForm = () => {
Ar1Service.getForm(formId,
(resp) => { setForm(resp); setLoading(false); },
(err) => {
setLoading(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Form non trovato' });
}
);
};
useEffect(() => { if (formId) refreshForm(); /* eslint-disable-next-line */ }, [formId]);
const handleGeneratePdf = () => {
setGenerating(true);
Ar1Service.generatePdf(formId,
() => {
setGenerating(false);
refreshForm();
if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'PDF generato' });
},
(err) => {
setGenerating(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Generazione PDF fallita' });
}
);
};
const handleDownloadUnsigned = async () => {
try {
const blob = await Ar1Service.downloadPdfUnsigned(formId);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `AR1_${form.variant}_da-firmare.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: e.message });
}
};
const handleUploadSigned = (event) => {
const file = event.files?.[0];
if (!file) return;
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
if (ext !== '.pdf' && ext !== '.p7m') {
if (toast.current) toast.current.show({ severity: 'warn', summary: 'Formato non valido', detail: 'Accettati: .pdf (PAdES) o .p7m (CAdES)' });
resetUploadInput();
return;
}
setUploading(true);
if (toast.current) toast.current.show({ severity: 'info', summary: 'Verifica in corso...', detail: 'Analisi firma digitale (fino a 60 secondi)' });
Ar1Service.uploadSignature(formId, file,
(resp) => {
setUploading(false);
resetUploadInput();
refreshForm();
const outcome = resp?.outcome;
if (outcome === 'VERIFIED') {
if (toast.current) toast.current.show({ severity: 'success', summary: 'Firma verificata!', detail: 'La dichiarazione e stata archiviata nei tuoi documenti aziendali.' });
setTimeout(() => navigate('/ar1'), 1500);
} else if (outcome === 'SIGNED_NOT_VERIFIED') {
if (toast.current) toast.current.show({ severity: 'warn', summary: 'Firma accettata', detail: 'La firma e presente ma richiede verifica manuale da parte dell\'istruttore.' });
} else if (outcome === 'SIGNED_DOCVERIFY_UNAVAILABLE') {
if (toast.current) toast.current.show({ severity: 'warn', summary: 'Verifica rimandata', detail: 'Servizio di verifica momentaneamente non disponibile. L\'istruttore verifichera la firma manualmente.' });
}
},
(err) => {
setUploading(false);
resetUploadInput();
if (err?.detail?.code === 'NO_SIGNATURE_DETECTED') {
if (toast.current) toast.current.show({
severity: 'error',
summary: 'Firma non rilevata',
detail: 'Il file caricato non contiene una firma digitale valida. Firmare il PDF con il proprio strumento FEQ e ricaricarlo.',
life: 6000
});
} else {
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: typeof err?.detail === 'string' ? err.detail : (err?.detail?.message || 'Upload fallito') });
}
}
);
};
if (loading) return <div style={{ textAlign: 'center', padding: 40 }}><ProgressSpinner /></div>;
if (!form) return <div style={{ padding: 20 }}><Message severity="error" text={__('Form non trovato', 'gepafin')} /></div>;
const hasUnsignedPdf = !!form.pdf_unsigned_path;
const hasSignedPdf = !!form.pdf_signed_path;
const canUploadSig = form.status === 'AWAITING_SIGNATURE';
const isDone = ['VERIFIED', 'SIGNED'].includes(form.status);
return (
<div style={{ padding: 16, maxWidth: 900, margin: '0 auto' }}>
<Toast ref={toast} />
<h1>{__('Firma AR1', 'gepafin')} {form.variant}</h1>
<div style={{ marginBottom: 20 }}><Ar1StatusTag status={form.status} /></div>
<Card title={__('1. Scarica il modulo AR1 da firmare', 'gepafin')} style={{ marginBottom: 14 }}>
{!hasUnsignedPdf && (
<div>
<p>{__('Il PDF del tuo modulo AR1 non e ancora stato generato.', 'gepafin')}</p>
<Button label={__('Genera PDF', 'gepafin')} icon="pi pi-file-pdf" onClick={handleGeneratePdf} loading={generating} />
</div>
)}
{hasUnsignedPdf && (
<div>
<p>{__('Scarica il PDF, firmalo con il tuo strumento FEQ (CNS, Aruba, Namirial, Dike) e ricaricalo qui sotto.', 'gepafin')}</p>
<Button label={__('Scarica PDF', 'gepafin')} icon="pi pi-download" onClick={handleDownloadUnsigned} outlined />
{!isDone && (
<Button label={__('Rigenera PDF', 'gepafin')} icon="pi pi-refresh" text onClick={handleGeneratePdf} loading={generating} style={{ marginLeft: 8 }} />
)}
</div>
)}
</Card>
<Card title={__('2. Carica il PDF firmato', 'gepafin')} style={{ marginBottom: 14 }}>
{!canUploadSig && !isDone && (
<Message severity="warn" text={__(`Il modulo non e in stato AWAITING_SIGNATURE (attuale: ${form.status})`, 'gepafin')} />
)}
{canUploadSig && (
<div>
<p>{__('Formati accettati: PDF con firma PAdES oppure file .p7m (CAdES). Dimensione massima 50 MB.', 'gepafin')}</p>
<FileUpload
ref={fileUploadRef}
key={uploadAttempt}
name="file"
mode="basic"
accept=".pdf,.p7m"
maxFileSize={50 * 1024 * 1024}
customUpload
uploadHandler={handleUploadSigned}
auto
chooseLabel={uploading ? __('Verifica in corso...', 'gepafin') : __('Seleziona PDF firmato', 'gepafin')}
disabled={uploading}
/>
</div>
)}
{isDone && (
<div>
<Message severity="success" text={
form.status === 'VERIFIED'
? __('Firma verificata con successo. La dichiarazione e archiviata nei tuoi documenti aziendali.', 'gepafin')
: __('Firma accettata. Potrebbe richiedere verifica manuale.', 'gepafin')
} style={{ marginBottom: 12 }} />
{hasSignedPdf && (
<Button label={__('Scarica PDF firmato', 'gepafin')} icon="pi pi-download" onClick={async () => {
try {
const blob = await Ar1Service.downloadPdfSigned(formId);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `AR1_${form.variant}_signed${form.pdf_signed_path?.endsWith('.p7m') ? '.p7m' : '.pdf'}`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: e.message });
}
}} outlined />
)}
</div>
)}
</Card>
{form.signature_verified_at && (
<Card title={__('Dettagli verifica', 'gepafin')}>
<p><strong>{__('Firmatario:', 'gepafin')}</strong> {form.signature_signer_name || ''}</p>
<p><strong>{__('Codice fiscale:', 'gepafin')}</strong> {form.signature_signer_cf || ''}</p>
<p><strong>{__('Metodo:', 'gepafin')}</strong> {form.signature_type || ''}</p>
<p><strong>{__('Verificato il:', 'gepafin')}</strong> {new Date(form.signature_verified_at).toLocaleString('it-IT')}</p>
<p><strong>{__('Scade il:', 'gepafin')}</strong> {form.expires_at ? new Date(form.expires_at).toLocaleDateString('it-IT') : ''}</p>
</Card>
)}
<div style={{ marginTop: 20, textAlign: 'center' }}>
<Button label={__('Torna alla Home AR1', 'gepafin')} severity="secondary" outlined onClick={() => navigate('/ar1')} />
</div>
</div>
);
};
export default Ar1Signature;

View File

@@ -0,0 +1,392 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { __ } from '@wordpress/i18n';
import { useNavigate, useParams } from 'react-router-dom';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { InputText } from 'primereact/inputtext';
import { InputTextarea } from 'primereact/inputtextarea';
import { Calendar } from 'primereact/calendar';
import { RadioButton } from 'primereact/radiobutton';
import { Checkbox } from 'primereact/checkbox';
import { Dropdown } from 'primereact/dropdown';
import { Toast } from 'primereact/toast';
import { Steps } from 'primereact/steps';
import { ProgressSpinner } from 'primereact/progressspinner';
import { Message } from 'primereact/message';
import { FileUpload } from 'primereact/fileupload';
import Ar1Service from '../service/ar1Service';
/**
* Wizard data-driven: legge schema_snapshot del form e genera step/field dinamicamente.
* Uno step per quadro. Auto-save onBlur via PUT /quadri.
*
* URL: /ar1/wizard/:formId
*/
const Ar1Wizard = () => {
const { formId } = useParams();
const navigate = useNavigate();
const toast = useRef(null);
const [form, setForm] = useState(null);
const [quadriValues, setQuadriValues] = useState({});
const [loading, setLoading] = useState(true);
const [activeIndex, setActiveIndex] = useState(0);
const [saving, setSaving] = useState(false);
const [submitting, setSubmitting] = useState(false);
const quadri = useMemo(
() => (form?.schema_snapshot?.quadri || []).filter(q => !q.is_legal_frame),
[form]
);
useEffect(() => {
if (!formId) return;
Ar1Service.getForm(formId,
(resp) => {
setForm(resp);
setQuadriValues(resp.quadri || {});
setLoading(false);
},
(err) => {
setLoading(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Form non trovato' });
}
);
}, [formId]);
const isReadonly = form && form.status !== 'DRAFT';
const saveQuadro = (quadroId) => {
if (isReadonly) return;
const patch = { [quadroId]: quadriValues[quadroId] || {} };
setSaving(true);
Ar1Service.updateQuadri(formId, patch,
(resp) => { setSaving(false); setForm(resp); },
(err) => {
setSaving(false);
if (toast.current) toast.current.show({ severity: 'warn', summary: 'Save fallito', detail: err?.detail || 'Riprovare' });
}
);
};
const handleFieldChange = (quadroId, fieldId, value) => {
setQuadriValues(prev => ({
...prev,
[quadroId]: { ...(prev[quadroId] || {}), [fieldId]: value }
}));
};
const handleRowFieldChange = (quadroId, rowIndex, fieldId, value) => {
setQuadriValues(prev => {
const q = prev[quadroId] || { rows: [] };
const rows = [...(q.rows || [])];
rows[rowIndex] = { ...(rows[rowIndex] || {}), [fieldId]: value };
return { ...prev, [quadroId]: { ...q, rows } };
});
};
const addRow = (quadroId, maxRows) => {
setQuadriValues(prev => {
const q = prev[quadroId] || { rows: [] };
if ((q.rows || []).length >= maxRows) return prev;
return { ...prev, [quadroId]: { ...q, rows: [...(q.rows || []), {}] } };
});
};
const removeRow = (quadroId, rowIndex) => {
setQuadriValues(prev => {
const q = prev[quadroId] || { rows: [] };
const rows = (q.rows || []).filter((_, i) => i !== rowIndex);
return { ...prev, [quadroId]: { ...q, rows } };
});
};
const submitFinale = () => {
if (!activeQuadro) return;
setSubmitting(true);
const patch = { [activeQuadro.id]: quadriValues[activeQuadro.id] || {} };
Ar1Service.updateQuadri(formId, patch,
() => {
Ar1Service.submitForSignature(formId,
() => {
setSubmitting(false);
if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'Modulo pronto per la firma' });
setTimeout(() => navigate(`/ar1/signature/${formId}`), 600);
},
(err) => {
setSubmitting(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Submit fallito' });
}
);
},
(err) => {
setSubmitting(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Save fallito' });
}
);
};
const renderField = (field, value, onChange, path = '') => {
const key = `${path}-${field.id}`;
const disabled = isReadonly;
const req = field.required ? ' *' : '';
const commonLabel = <label htmlFor={key} style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>{field.label}{req}</label>;
switch (field.type) {
case 'text':
case 'email':
return (
<div key={key} style={{ marginBottom: 14 }}>
{commonLabel}
<InputText
id={key}
value={value || ''}
onChange={(e) => onChange(field.id, field.uppercase ? e.target.value.toUpperCase() : e.target.value)}
disabled={disabled}
maxLength={field.max_length}
placeholder={field.placeholder}
style={{ width: '100%' }}
/>
{field.legal_ref && <small style={{ color: '#888' }}>{field.legal_ref}</small>}
</div>
);
case 'textarea':
return (
<div key={key} style={{ marginBottom: 14 }}>
{commonLabel}
<InputTextarea id={key} value={value || ''} onChange={(e) => onChange(field.id, e.target.value)} disabled={disabled} rows={3} maxLength={field.max_length} style={{ width: '100%' }} />
</div>
);
case 'date':
return (
<div key={key} style={{ marginBottom: 14 }}>
{commonLabel}
<Calendar id={key} value={value ? new Date(value) : null} onChange={(e) => onChange(field.id, e.value ? e.value.toISOString().slice(0, 10) : null)} disabled={disabled} dateFormat="dd/mm/yy" showIcon style={{ width: '100%' }} />
</div>
);
case 'checkbox':
return (
<div key={key} style={{ marginBottom: 14, display: 'flex', alignItems: 'center', gap: 8 }}>
<Checkbox inputId={key} checked={!!value} onChange={(e) => onChange(field.id, e.checked)} disabled={disabled} />
<label htmlFor={key}>{field.label}</label>
</div>
);
case 'radio':
return (
<div key={key} style={{ marginBottom: 14 }}>
{commonLabel}
{(field.options || []).map((opt, idx) => {
const optVal = typeof opt === 'string' ? opt : opt.value;
const optLabel = typeof opt === 'string' ? opt : opt.label;
const rid = `${key}-opt-${idx}`;
return (
<div key={rid} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<RadioButton inputId={rid} value={optVal} checked={value === optVal} onChange={(e) => onChange(field.id, e.value)} disabled={disabled} />
<label htmlFor={rid}>{optLabel}</label>
</div>
);
})}
</div>
);
case 'enum':
return (
<div key={key} style={{ marginBottom: 14 }}>
{commonLabel}
<Dropdown id={key} value={value} options={(field.options || []).map(o => ({ label: o.replace(/_/g, ' '), value: o }))} onChange={(e) => onChange(field.id, e.value)} disabled={disabled} style={{ width: '100%' }} showClear />
</div>
);
case 'yes_no_with_note': {
const v = typeof value === 'object' && value ? value : {};
const yes = v.value === 'si' || v.value === 'yes' || v.value === 'true';
const no = v.value === 'no' || v.value === 'false';
return (
<div key={key} style={{ marginBottom: 14, padding: 10, background: '#fafafa', borderLeft: '3px solid #003d7a' }}>
{commonLabel}
{field.legal_ref && <small style={{ color: '#888', display: 'block', marginBottom: 6 }}>{field.legal_ref}</small>}
<div style={{ display: 'flex', gap: 16, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<RadioButton inputId={`${key}-yes`} checked={yes} onChange={() => onChange(field.id, { ...v, value: 'si' })} disabled={disabled} />
<label htmlFor={`${key}-yes`}>{__('Si', 'gepafin')}</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<RadioButton inputId={`${key}-no`} checked={no} onChange={() => onChange(field.id, { ...v, value: 'no' })} disabled={disabled} />
<label htmlFor={`${key}-no`}>{__('No', 'gepafin')}</label>
</div>
</div>
{yes && (
<InputTextarea
value={v.note || ''}
onChange={(e) => onChange(field.id, { ...v, note: e.target.value })}
disabled={disabled}
rows={2}
placeholder={field.note_label || __('Specificare', 'gepafin')}
style={{ width: '100%' }}
/>
)}
</div>
);
}
default:
return (
<div key={key} style={{ marginBottom: 14 }}>
{commonLabel}
<InputText value={value || ''} onChange={(e) => onChange(field.id, e.target.value)} disabled={disabled} style={{ width: '100%' }} />
</div>
);
}
};
const renderQuadro = (quadro) => {
const q = quadriValues[quadro.id] || {};
if (quadro.upload_slots) {
return (
<div>
<h3>{quadro.title}</h3>
{quadro.description && <p style={{ color: '#666' }}>{quadro.description}</p>}
<Message severity="info" text={__('Carica qui i documenti richiesti. Il PDF firmato dell\'AR1 verra archiviato automaticamente nei tuoi documenti aziendali.', 'gepafin')} style={{ marginBottom: 14 }} />
{quadro.upload_slots.map(slot => (
<div key={slot.id} style={{ marginBottom: 14, padding: 10, border: '1px solid #ddd', borderRadius: 4 }}>
<label style={{ fontWeight: 500 }}>{slot.label}{slot.required ? ' *' : ''}</label>
{q[slot.id]?.filename ? (
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, padding: 8, background: '#f0f7ff', border: '1px solid #b3d4f0', borderRadius: 4 }}>
<i className="pi pi-file" style={{ color: '#003d7a' }} />
<span style={{ flex: 1, wordBreak: 'break-all' }}>{q[slot.id].filename}</span>
<Button
icon="pi pi-times"
label={__('Rimuovi', 'gepafin')}
severity="danger"
outlined
size="small"
disabled={isReadonly}
onClick={() => handleFieldChange(quadro.id, slot.id, null)}
/>
</div>
) : (
<FileUpload
name={slot.id}
mode="basic"
accept={(slot.accept || []).join(',')}
maxFileSize={(slot.max_size_mb || 300) * 1024 * 1024}
disabled={isReadonly}
customUpload
uploadHandler={(e) => {
const file = e.files[0];
handleFieldChange(quadro.id, slot.id, { filename: file.name, size: file.size });
if (toast.current) toast.current.show({ severity: 'info', summary: 'File selezionato', detail: file.name });
}}
auto
chooseLabel={__('Scegli file', 'gepafin')}
style={{ marginTop: 6 }}
/>
)}
</div>
))}
</div>
);
}
if (quadro.row_type) {
const rows = q.rows || [];
return (
<div>
<h3>{quadro.title}</h3>
{quadro.description && <p style={{ color: '#666' }}>{quadro.description}</p>}
{rows.map((row, idx) => (
<Card key={idx} title={`${quadro.row_type.replace(/_/g, ' ').toUpperCase()} #${idx + 1}`} style={{ marginBottom: 12 }}>
{(quadro.row_fields || []).map(field => renderField(field, row[field.id], (fid, val) => handleRowFieldChange(quadro.id, idx, fid, val), `q-${quadro.id}-row-${idx}`))}
{!isReadonly && (
<Button label={__('Rimuovi', 'gepafin')} severity="danger" outlined icon="pi pi-trash" size="small" onClick={() => removeRow(quadro.id, idx)} />
)}
</Card>
))}
{!isReadonly && rows.length < (quadro.max_rows || 4) && (
<Button label={__('Aggiungi', 'gepafin')} icon="pi pi-plus" outlined onClick={() => addRow(quadro.id, quadro.max_rows || 4)} />
)}
</div>
);
}
return (
<div>
<h3>{quadro.title}</h3>
{quadro.description && <p style={{ color: '#666' }}>{quadro.description}</p>}
{(quadro.fields || []).map(field => renderField(field, q[field.id], (fid, val) => handleFieldChange(quadro.id, fid, val), `q-${quadro.id}`))}
{quadro.nested_full && (
<div style={{ marginTop: 16, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
<h4 style={{ marginTop: 0 }}>{__('Dettaglio aggiuntivo', 'gepafin')}</h4>
{(quadro.nested_full.fields || []).map(field => renderField(
field,
(q.nested || {})[field.id],
(fid, val) => handleFieldChange(quadro.id, 'nested', { ...((q.nested) || {}), [fid]: val }),
`q-${quadro.id}-nested`
))}
</div>
)}
</div>
);
};
if (loading) return <div style={{ textAlign: 'center', padding: 40 }}><ProgressSpinner /></div>;
if (!form) return <div style={{ padding: 20 }}><Message severity="error" text={__('Form non trovato', 'gepafin')} /></div>;
if (quadri.length === 0) return <div style={{ padding: 20 }}><Message severity="warn" text={__('Nessun quadro editabile nel template. Contattare il supporto.', 'gepafin')} /></div>;
const steps = quadri.map(q => ({ label: q.id }));
// clamp activeIndex: difensivo se quadri cambia lunghezza o e fuori range
const safeIndex = quadri.length === 0 ? 0 : Math.max(0, Math.min(activeIndex, quadri.length - 1));
const activeQuadro = quadri[safeIndex];
const isLastStep = safeIndex === quadri.length - 1;
return (
<div style={{ padding: 16 }}>
<Toast ref={toast} />
<h1>{__('Compilazione AR1', 'gepafin')} {form.variant}</h1>
{isReadonly && (
<Message severity="info" text={__(`Form in stato ${form.status} — sola lettura`, 'gepafin')} style={{ marginBottom: 14 }} />
)}
<Steps
model={steps}
activeIndex={safeIndex}
onSelect={(e) => {
if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id);
const next = Math.max(0, Math.min(e.index, quadri.length - 1));
setActiveIndex(next);
}}
readOnly={false}
style={{ marginBottom: 20 }}
/>
<Card style={{ marginBottom: 14 }} onBlur={() => { if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id); }}>
{activeQuadro && renderQuadro(activeQuadro)}
</Card>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button label={__('Indietro', 'gepafin')} icon="pi pi-arrow-left" severity="secondary" outlined disabled={activeIndex === 0}
onClick={() => {
if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id);
setActiveIndex(Math.max(0, activeIndex - 1));
}}
/>
<div style={{ display: 'flex', gap: 8 }}>
{saving && <span style={{ color: '#888' }}>{__('Salvataggio...', 'gepafin')}</span>}
{!isLastStep && (
<Button label={__('Avanti', 'gepafin')} icon="pi pi-arrow-right" iconPos="right"
onClick={() => {
if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id);
setActiveIndex(Math.min(quadri.length - 1, activeIndex + 1));
}}
/>
)}
{isLastStep && !isReadonly && (
<Button label={__('Procedi alla firma', 'gepafin')} icon="pi pi-verified" severity="warning" loading={submitting} onClick={submitFinale} />
)}
</div>
</div>
</div>
);
};
export default Ar1Wizard;

View File

@@ -0,0 +1,259 @@
/**
* Client HTTP per ar1-compiler (microservizio BFLOWS).
* Il microservizio valida lo stesso JWT di GEPAFIN-BE (HS512 shared secret).
*
* Env var: REACT_APP_AR1_API_URL (es. http://78.46.41.91:18091)
*
* Pattern replicato 1:1 da rendicontazioneService.js.
*/
import { storeGet } from '../../../store';
const BASE_URL = process.env.REACT_APP_AR1_API_URL || '';
const buildHeaders = () => {
const token = storeGet('getToken');
const h = { 'Content-Type': 'application/json' };
if (token) h['Authorization'] = `Bearer ${token}`;
return h;
};
const buildHeadersMultipart = () => {
const token = storeGet('getToken');
const h = {};
if (token) h['Authorization'] = `Bearer ${token}`;
return h;
};
const handleResponse = async (response, onSuccess, onError) => {
let body = null;
try { body = await response.json(); } catch (e) { body = { detail: response.statusText }; }
if (response.status >= 200 && response.status < 300) {
if (onSuccess) onSuccess(body);
} else {
if (onError) onError({ status: response.status, ...body });
}
};
const handleError = (err, onError) => {
if (onError) onError({ status: 0, detail: err.message });
};
const Ar1Service = {
// ---------- Status pubblico (per compliance modal) ----------
getStatusForCompany(companyId, onSuccess, onError) {
fetch(`${BASE_URL}/public/ar1-status/${companyId}`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
// ---------- CRUD form beneficiario ----------
createDraft(companyId, variant, onSuccess, onError) {
fetch(`${BASE_URL}/api/ar1-forms`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ company_id: companyId, variant })
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
getForm(formId, onSuccess, onError) {
fetch(`${BASE_URL}/api/ar1-forms/${formId}`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
listFormsForCompany(companyId, onSuccess, onError) {
fetch(`${BASE_URL}/api/ar1-forms/company/${companyId}`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
updateQuadri(formId, quadriPatch, onSuccess, onError) {
fetch(`${BASE_URL}/api/ar1-forms/${formId}/quadri`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ quadri: quadriPatch })
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
submitForSignature(formId, onSuccess, onError) {
fetch(`${BASE_URL}/api/ar1-forms/${formId}/submit-for-signature`, {
method: 'PUT', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
deleteForm(formId, onSuccess, onError) {
fetch(`${BASE_URL}/api/ar1-forms/${formId}`, {
method: 'DELETE', mode: 'cors', headers: buildHeaders()
}).then(r => {
if (r.status === 204) { if (onSuccess) onSuccess({}); }
else handleResponse(r, onSuccess, onError);
}).catch(e => handleError(e, onError));
},
// ---------- PDF ----------
generatePdf(formId, onSuccess, onError) {
fetch(`${BASE_URL}/api/ar1-forms/${formId}/generate-pdf`, {
method: 'POST', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
downloadPdfUnsigned(formId) {
return fetch(`${BASE_URL}/api/ar1-forms/${formId}/pdf-unsigned`, {
method: 'GET', mode: 'cors', headers: buildHeadersMultipart()
}).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); });
},
downloadPdfSigned(formId) {
return fetch(`${BASE_URL}/api/ar1-forms/${formId}/pdf-signed`, {
method: 'GET', mode: 'cors', headers: buildHeadersMultipart()
}).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); });
},
// ---------- Firma ----------
uploadSignature(formId, fileObject, onSuccess, onError) {
const formData = new FormData();
formData.append('file', fileObject);
fetch(`${BASE_URL}/api/ar1-forms/${formId}/upload-signature`, {
method: 'POST', mode: 'cors', headers: buildHeadersMultipart(),
body: formData
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
reVerifySignature(formId, onSuccess, onError) {
fetch(`${BASE_URL}/api/ar1-forms/${formId}/verify`, {
method: 'POST', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
archiveToCompanyDocument(formId, onSuccess, onError) {
fetch(`${BASE_URL}/api/ar1-forms/${formId}/archive-to-company-document`, {
method: 'POST', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
// ---------- ADMIN: templates ----------
listTemplates(onSuccess, onError, queryParams) {
const qs = queryParams ? ('?' + new URLSearchParams(queryParams).toString()) : '';
fetch(`${BASE_URL}/admin/ar1-templates${qs}`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
getTemplateDetail(templateId, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-templates/${templateId}`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
getNextVersion(variant, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-templates/${variant}/next-version`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
updateTemplateLayout(templateId, layoutConfig, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-templates/${templateId}/layout-config`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ layout_config: layoutConfig })
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
createNewTemplateVersion(variant, payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-templates/${variant}/new-version`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
// ---------- ADMIN: policy ----------
getPolicy(onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-policy`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
updatePolicy(patch, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-policy`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(patch)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
// ---------- ADMIN: pec-schedule-config ----------
listPecSchedule(onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
createPecRule(payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
updatePecRule(ruleId, payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config/${ruleId}`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
deletePecRule(ruleId, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config/${ruleId}`, {
method: 'DELETE', mode: 'cors', headers: buildHeaders()
}).then(r => {
if (r.status === 204) { if (onSuccess) onSuccess({}); }
else handleResponse(r, onSuccess, onError);
}).catch(e => handleError(e, onError));
},
// ---------- ADMIN: bulk PEC ----------
bulkRequestRecompilation(payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-forms/bulk-request-recompilation`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
// ---------- ADMIN: document categories (per dropdown) ----------
previewTemplatePdf(templateId) {
return fetch(`${BASE_URL}/admin/ar1-templates/${templateId}/preview-pdf`, {
method: 'POST', mode: 'cors', headers: buildHeadersMultipart()
}).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); });
},
listDocumentCategories(onSuccess, onError) {
fetch(`${BASE_URL}/admin/document-categories`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
// ---------- ADMIN: email templates (Opzione 3 — tenant-agnostic, BE Gepafin pull) ----------
listEmailTemplates(onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-email-templates`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
getEmailTemplate(kind, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-email-templates/${kind}`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
updateEmailTemplate(kind, payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-email-templates/${kind}`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
previewEmailTemplate(kind, mockVars, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-email-templates/${kind}/preview`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(mockVars || {})
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
};
export default Ar1Service;

View File

@@ -0,0 +1,284 @@
import React, { useEffect, useState, useMemo } from 'react';
import { __ } from '@wordpress/i18n';
import { Dialog } from 'primereact/dialog';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
import { Skeleton } from 'primereact/skeleton';
import { Message } from 'primereact/message';
import CompanyDocumentsService from '../../../service/company-documents-service';
/**
* Modal per scegliere un documento dal repository della company.
*
* Carica tramite 2 chiamate al BE Spring:
* - default (no filter) -> COMPANY_DOCUMENT + APPLICATION_DOCUMENT
* - documentType=PERSONAL -> PERSONAL_DOCUMENT (amministratore/legale rappresentante)
*
* Unione dei risultati e deduplicazione per document_category_id scegliendo
* il piu recente attivo (VALID > DUE, a parita id desc). Questo mitiga il
* fatto che il BE Gepafin non implementa sostituzione automatica su upload
* stessa categoria (es. 2 DURC attivi in DB).
*
* Props:
* visible, companyId, onHide, onSelect(doc), currentSourceId
*/
const STATUS_CFG = {
VALID: { severity: 'success', label: 'Valido', icon: 'pi pi-check-circle' },
DUE: { severity: 'warning', label: 'In scadenza', icon: 'pi pi-exclamation-triangle' },
EXPIRED: { severity: 'danger', label: 'Scaduto', icon: 'pi pi-times-circle' },
};
const ORIGIN_CFG = {
COMPANY_DOCUMENT: { label: 'Azienda', icon: 'pi pi-building', color: 'var(--blue-500)', bg: 'var(--blue-50)' },
PERSONAL_DOCUMENT: { label: 'Amministratore', icon: 'pi pi-user', color: 'var(--purple-500)', bg: 'var(--purple-50)' },
APPLICATION_DOCUMENT: { label: 'Applicazione', icon: 'pi pi-file', color: 'var(--teal-500)', bg: 'var(--teal-50)' },
};
const formatDate = (d) => {
if (!d) return '—';
try { return new Date(d).toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric' }); }
catch { return String(d); }
};
const daysUntil = (d) => { if (!d) return null; try { return Math.ceil((new Date(d).getTime() - Date.now()) / 86400000); } catch { return null; } };
const getCategoryName = (d) => (d.category && d.category.categoryName) || d.type || '—';
const getOriginType = (d) => d.type || 'COMPANY_DOCUMENT';
// dedup: per ogni (type + category), mantieni il piu recente attivo. VALID > DUE, poi id desc
const dedupByCategory = (docs) => {
const STATUS_RANK = { VALID: 0, DUE: 1, EXPIRED: 2 };
const groups = new Map();
for (const d of docs) {
const key = `${d.type || 'X'}::${getCategoryName(d)}`;
const rank = (STATUS_RANK[d.status] ?? 99) * 1e12 - (d.id || 0);
const prev = groups.get(key);
if (!prev || rank < prev._rank) {
groups.set(key, { ...d, _rank: rank });
}
}
return Array.from(groups.values()).map(({ _rank, ...rest }) => rest);
};
const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSourceId = null }) => {
const [loading, setLoading] = useState(false);
const [docs, setDocs] = useState([]);
const [error, setError] = useState(null);
const [selectedId, setSelectedId] = useState(null);
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState(null);
const [statusFilter, setStatusFilter] = useState(null);
const [originFilter, setOriginFilter] = useState('ALL'); // ALL | COMPANY_DOCUMENT | PERSONAL_DOCUMENT
useEffect(() => {
if (!visible || !companyId) return;
setLoading(true);
setError(null);
setSelectedId(currentSourceId);
setSearch(''); setCategoryFilter(null); setStatusFilter(null); setOriginFilter('ALL');
// doppia chiamata: company/application + personal
let collected = [];
let pending = 2;
const done = () => { if (--pending === 0) { setDocs(dedupByCategory(collected)); setLoading(false); } };
CompanyDocumentsService.getCompanyDocuments(
companyId,
(resp) => { if (resp?.status === 'SUCCESS') collected = collected.concat(resp.data || []); done(); },
() => { setError(__('Impossibile caricare i documenti del repository.', 'gepafin')); done(); }
);
CompanyDocumentsService.getCompanyDocuments(
companyId,
(resp) => { if (resp?.status === 'SUCCESS') collected = collected.concat(resp.data || []); done(); },
() => done(),
[['documentType', 'PERSONAL_DOCUMENT']]
);
}, [visible, companyId, currentSourceId]);
// opzioni filtri derivate
const categoryOptions = useMemo(() => Array.from(new Set(docs.map(getCategoryName).filter(Boolean))).sort().map(t => ({ label: t, value: t })), [docs]);
const statusOptions = [
{ label: __('Validi', 'gepafin'), value: 'VALID' },
{ label: __('In scadenza', 'gepafin'), value: 'DUE' },
];
const filteredDocs = useMemo(() => {
const s = (search || '').trim().toLowerCase();
return docs.filter(d => {
if (originFilter !== 'ALL' && getOriginType(d) !== originFilter) return false;
if (categoryFilter && getCategoryName(d) !== categoryFilter) return false;
if (statusFilter && d.status !== statusFilter) return false;
if (s) {
const hay = [d.fileName, d.name, getCategoryName(d)].filter(Boolean).join(' ').toLowerCase();
if (!hay.includes(s)) return false;
}
return true;
});
}, [docs, search, categoryFilter, statusFilter, originFilter]);
// templates colonne
const nameTpl = (row) => (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<i className="pi pi-file-pdf" style={{ color: 'var(--primary-color)', fontSize: '1.1rem' }} />
<strong style={{ fontSize: '0.95rem' }}>{row.fileName || row.name || `Doc #${row.id}`}</strong>
</div>
{row.name && row.name !== row.fileName && (
<div style={{ marginLeft: '1.6rem' }}><small className="text-color-secondary">{row.name}</small></div>
)}
</div>
);
const originTpl = (row) => {
const cfg = ORIGIN_CFG[getOriginType(row)] || ORIGIN_CFG.COMPANY_DOCUMENT;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: '0.35rem',
background: cfg.bg, color: cfg.color, padding: '0.2rem 0.6rem',
borderRadius: '12px', fontSize: '0.8rem', fontWeight: 500
}}>
<i className={cfg.icon} style={{ fontSize: '0.8rem' }} />
{cfg.label}
</span>
);
};
const categoryTpl = (row) => (
<Tag value={getCategoryName(row)} severity="secondary"
style={{ fontFamily: 'monospace', fontSize: '0.8rem' }} />
);
const statusTpl = (row) => {
const cfg = STATUS_CFG[row.status] || { severity: 'secondary', label: row.status || '—', icon: 'pi pi-question' };
const days = daysUntil(row.expirationDate);
return (
<div>
<Tag severity={cfg.severity} icon={cfg.icon} value={cfg.label} style={{ fontWeight: 600 }} />
{row.status === 'DUE' && days != null && days >= 0 && (
<div><small className="text-color-secondary">scade tra {days} {days === 1 ? 'giorno' : 'giorni'}</small></div>
)}
</div>
);
};
const expiryTpl = (row) => <div style={{ fontFamily: 'monospace', fontSize: '0.9rem' }}>{formatDate(row.expirationDate)}</div>;
const chosenDoc = useMemo(() => docs.find(d => d.id === selectedId) || null, [docs, selectedId]);
const footer = (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', gap: '1rem' }}>
<div style={{ flex: 1, minWidth: 0 }}>
{chosenDoc ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<small className="text-color-secondary">{__('Selezionato', 'gepafin')}:</small>
<strong style={{ fontSize: '0.9rem' }}>{chosenDoc.fileName || chosenDoc.name}</strong>
<Tag severity={(STATUS_CFG[chosenDoc.status]||{}).severity || 'secondary'}
icon={(STATUS_CFG[chosenDoc.status]||{}).icon}
value={(STATUS_CFG[chosenDoc.status]||{}).label || chosenDoc.status} />
</div>
) : (
<small className="text-color-secondary">{__('Seleziona una riga per continuare', 'gepafin')}</small>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<Button label={__('Annulla', 'gepafin')} severity="secondary" text onClick={onHide} />
<Button label={__('Seleziona', 'gepafin')} icon="pi pi-check" disabled={!chosenDoc}
onClick={() => { if (chosenDoc) { onSelect(chosenDoc); onHide(); } }} />
</div>
</div>
);
const dialogHeader = (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<i className="pi pi-folder-open" style={{ color: 'var(--primary-color)', fontSize: '1.3rem' }} />
<span>{__('Scegli dal repository aziendale', 'gepafin')}</span>
{!loading && !error && (
<small className="text-color-secondary" style={{ marginLeft: 'auto' }}>
{filteredDocs.length} / {docs.length} {__('documenti', 'gepafin')}
</small>
)}
</div>
);
return (
<Dialog header={dialogHeader} visible={visible} onHide={onHide}
style={{ width: '1000px', maxWidth: '95vw' }}
modal dismissableMask footer={footer}
contentStyle={{ paddingTop: '1rem' }}>
<Message severity="info" style={{ width: '100%', marginBottom: '1.5rem' }}
content={
<small>{__('Seleziona un documento gia caricato. Se esistono piu versioni per la stessa categoria, viene mostrata solo la piu recente valida. I documenti scaduti sono esclusi.', 'gepafin')}</small>
} />
{/* tab origine — 3 pulsanti manuali (SelectButton di PrimeReact ha issues di styling col tema Gepafin) */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
<Button type="button" size="small"
label={__('Tutti', 'gepafin')}
outlined={originFilter !== 'ALL'}
onClick={() => setOriginFilter('ALL')}
style={{ flex: 1 }} />
<Button type="button" size="small" icon="pi pi-building"
label={__('Azienda', 'gepafin')}
outlined={originFilter !== 'COMPANY_DOCUMENT'}
onClick={() => setOriginFilter('COMPANY_DOCUMENT')}
style={{ flex: 1 }} />
<Button type="button" size="small" icon="pi pi-user"
label={__('Amministratore', 'gepafin')}
outlined={originFilter !== 'PERSONAL_DOCUMENT'}
onClick={() => setOriginFilter('PERSONAL_DOCUMENT')}
style={{ flex: 1 }} />
</div>
{/* filtri secondari */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{ position: 'relative', width: '100%' }}>
<i className="pi pi-search" style={{ position: 'absolute', left: '1rem', top: '50%',
transform: 'translateY(-50%)', color: 'var(--text-color-secondary)',
pointerEvents: 'none', zIndex: 1 }} />
<InputText placeholder={__('Cerca per nome o categoria...', 'gepafin')}
value={search} onChange={(e) => setSearch(e.target.value)}
style={{ width: '100%', paddingLeft: '2.75rem' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<Dropdown value={categoryFilter} onChange={(e) => setCategoryFilter(e.value)}
options={categoryOptions} placeholder={__('Categoria', 'gepafin')}
showClear style={{ width: '100%' }} />
<Dropdown value={statusFilter} onChange={(e) => setStatusFilter(e.value)}
options={statusOptions} placeholder={__('Stato', 'gepafin')}
showClear style={{ width: '100%' }} />
</div>
</div>
{loading ? (
<div><Skeleton height="2.5rem" className="mb-2" /><Skeleton height="2.5rem" className="mb-2" /><Skeleton height="2.5rem" /></div>
) : error ? (
<Message severity="error" text={error} style={{ width: '100%' }} />
) : (
<DataTable value={filteredDocs} dataKey="id"
emptyMessage={<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-color-secondary)' }}>
<i className="pi pi-inbox" style={{ fontSize: '2rem', display: 'block', marginBottom: '0.5rem' }} />
{docs.length === 0
? __('Nessun documento nel repository della tua azienda', 'gepafin')
: __('Nessun documento corrisponde ai filtri', 'gepafin')}
</div>}
size="small" stripedRows
paginator rows={10}
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
currentPageReportTemplate="{first}-{last} di {totalRecords}"
selectionMode="single" selection={chosenDoc}
onSelectionChange={(e) => e.value && setSelectedId(e.value.id)}
rowClassName={(row) => row.id === selectedId ? 'p-highlight' : ''}
style={{ cursor: 'pointer' }}>
<Column header={__('Documento', 'gepafin')} body={nameTpl} style={{ minWidth: '240px' }} />
<Column header={__('Origine', 'gepafin')} body={originTpl} style={{ width: '150px' }} />
<Column header={__('Categoria', 'gepafin')} body={categoryTpl} style={{ width: '170px' }} />
<Column header={__('Stato', 'gepafin')} body={statusTpl} style={{ width: '160px' }} />
<Column header={__('Scadenza', 'gepafin')} body={expiryTpl} style={{ width: '110px' }} />
</DataTable>
)}
</Dialog>
);
};
export default CompanyDocumentPicker;

View File

@@ -0,0 +1,198 @@
import React, { useEffect, useState } from 'react';
import { __ } from '@wordpress/i18n';
import { Button } from 'primereact/button';
import { Card } from 'primereact/card';
import { Dropdown } from 'primereact/dropdown';
import { Message } from 'primereact/message';
import { Skeleton } from 'primereact/skeleton';
import { schemaPickerService } from '../service/rendicontazioneService';
/**
* SchemaTemplatePicker
* Mostrato quando un bando non ha ancora uno schema di rendicontazione.
* Offre 3 modalita: schema nuovo vuoto, da template predefinito, clone da altro bando.
*
* Props:
* callId number — bando target
* onInitialized fn(schemaData) — chiamato dopo initialize con successo
* onError fn(err)
*/
const SchemaTemplatePicker = ({ callId, onInitialized, onError }) => {
const [templates, setTemplates] = useState([]);
const [clonableCalls, setClonableCalls] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Selezione utente
const [mode, setMode] = useState(null); // null | 'blank' | 'template' | 'clone'
const [pickedTemplateId, setPickedTemplateId] = useState(null);
const [pickedSourceCallId, setPickedSourceCallId] = useState(null);
useEffect(() => {
let active = true;
setLoading(true);
let tpls = null, clones = null;
const finish = () => {
if (tpls !== null && clones !== null && active) setLoading(false);
};
schemaPickerService.listTemplates(
(resp) => { if (active) { tpls = resp?.data?.templates || []; setTemplates(tpls); } finish(); },
(err) => { tpls = []; finish(); if (onError) onError(err); }
);
schemaPickerService.listClonableCalls(
(resp) => { if (active) { clones = resp?.data?.calls || []; setClonableCalls(clones); } finish(); },
(err) => { clones = []; finish(); }
);
return () => { active = false; };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const canSubmit = () => {
if (!mode) return false;
if (mode === 'blank') return true;
if (mode === 'template') return !!pickedTemplateId;
if (mode === 'clone') return !!pickedSourceCallId;
return false;
};
const handleSubmit = () => {
const payload = { source: mode };
if (mode === 'template') payload.template_id = pickedTemplateId;
if (mode === 'clone') payload.source_call_id = pickedSourceCallId;
setSaving(true);
schemaPickerService.initializeSchema(callId, payload,
(resp) => { setSaving(false); if (onInitialized) onInitialized(resp?.data); },
(err) => { setSaving(false); if (onError) onError(err); }
);
};
if (loading) {
return (
<div className="grid">
<div className="col-12 md:col-4"><Skeleton height="14rem"/></div>
<div className="col-12 md:col-4"><Skeleton height="14rem"/></div>
<div className="col-12 md:col-4"><Skeleton height="14rem"/></div>
</div>
);
}
const templateOptions = templates
.filter(t => t.template_id !== 'blank') // blank ha card dedicata
.map(t => ({ label: t.label, value: t.template_id, description: t.description }));
const cloneOptions = clonableCalls.map(c => ({
label: `${c.name} · ${c.schema_status === 'PUBLISHED' ? __('Pubblicato','gepafin') : __('Bozza','gepafin')}`,
value: c.call_id,
}));
const cardStyle = (selected) => ({
cursor: 'pointer',
height: '100%',
border: selected ? '2px solid var(--primary-color)' : '2px solid transparent',
transition: 'border-color 0.15s'
});
return (
<div>
<div style={{ marginBottom: '1rem' }}>
<h3>{__('Scegli come iniziare lo schema di rendicontazione','gepafin')}</h3>
<p className="text-color-secondary" style={{ marginTop: 0 }}>
{__("Puoi partire da un modello vuoto, usare un template predefinito oppure clonare lo schema di un altro bando. Lo schema creato sarà in bozza e modificabile liberamente.", 'gepafin')}
</p>
</div>
<div className="grid">
{/* Card 1: NUOVO SCHEMA VUOTO */}
<div className="col-12 md:col-4">
<Card
title={<><i className="pi pi-plus-circle" style={{ marginRight: '0.5rem', color: 'var(--primary-color)' }} />{__('Nuovo schema','gepafin')}</>}
style={cardStyle(mode === 'blank')}
onClick={() => setMode('blank')}
>
<p style={{ fontSize: '0.9em' }}>
{__('Parti da zero con sezioni vuote. Configurerai categorie di spesa, documenti richiesti e controlli aggiuntivi secondo le esigenze del bando.','gepafin')}
</p>
<div style={{ fontSize: '0.85em', color: 'var(--text-color-secondary)' }}>
{__('Consigliato se il bando è nuovo e non somiglia a bandi precedenti.','gepafin')}
</div>
</Card>
</div>
{/* Card 2: DA TEMPLATE */}
<div className="col-12 md:col-4">
<Card
title={<><i className="pi pi-book" style={{ marginRight: '0.5rem', color: 'var(--primary-color)' }} />{__('Da template','gepafin')}</>}
style={cardStyle(mode === 'template')}
onClick={() => setMode('template')}
>
<p style={{ fontSize: '0.9em' }}>
{__('Parti da un template predefinito che replica schemi di bandi noti (es. RE-START). Potrai comunque modificare tutto.','gepafin')}
</p>
{mode === 'template' && (
<div style={{ marginTop: '0.5rem' }} onClick={(e) => e.stopPropagation()}>
<Dropdown
value={pickedTemplateId}
onChange={(e) => setPickedTemplateId(e.value)}
options={templateOptions}
placeholder={__('Scegli un template...','gepafin')}
style={{ width: '100%' }}
/>
{pickedTemplateId && (
<small className="text-color-secondary" style={{ display: 'block', marginTop: '0.4rem' }}>
{templateOptions.find(t => t.value === pickedTemplateId)?.description}
</small>
)}
</div>
)}
</Card>
</div>
{/* Card 3: CLONE */}
<div className="col-12 md:col-4">
<Card
title={<><i className="pi pi-copy" style={{ marginRight: '0.5rem', color: 'var(--primary-color)' }} />{__('Clona da bando','gepafin')}</>}
style={cardStyle(mode === 'clone')}
onClick={() => setMode('clone')}
>
<p style={{ fontSize: '0.9em' }}>
{__('Copia lo schema di un altro bando (in bozza o pubblicato). Utile se il nuovo bando è molto simile a uno esistente.','gepafin')}
</p>
{mode === 'clone' && (
<div style={{ marginTop: '0.5rem' }} onClick={(e) => e.stopPropagation()}>
{cloneOptions.length === 0 ? (
<Message severity="warn"
text={__('Nessun bando con schema configurato da cui clonare.','gepafin')}
style={{ width: '100%' }} />
) : (
<Dropdown
value={pickedSourceCallId}
onChange={(e) => setPickedSourceCallId(e.value)}
options={cloneOptions}
placeholder={__('Scegli un bando sorgente...','gepafin')}
style={{ width: '100%' }}
/>
)}
</div>
)}
</Card>
</div>
</div>
<div style={{ marginTop: '1.5rem', display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
<Button
label={__('Inizia','gepafin')}
icon="pi pi-arrow-right"
iconPos="right"
disabled={!canSubmit() || saving}
loading={saving}
onClick={handleSubmit}
/>
</div>
</div>
);
};
export default SchemaTemplatePicker;

View File

@@ -22,6 +22,7 @@ import BlockingOverlay from '../../../components/BlockingOverlay';
// api // api
import RendicontazioneService from '../service/rendicontazioneService'; import RendicontazioneService from '../service/rendicontazioneService';
import SchemaTemplatePicker from '../components/SchemaTemplatePicker';
import BandoService from '../../../service/bando-service'; import BandoService from '../../../service/bando-service';
// ---------- costanti ---------- // ---------- costanti ----------
@@ -88,7 +89,16 @@ const schemaJsonToForm = (j) => {
cap_absolute: gate.cap_absolute ?? 12500, cap_absolute: gate.cap_absolute ?? 12500,
require_invoice_per_category: gate.require_at_least_one_invoice_per_nonzero_category ?? true, require_invoice_per_category: gate.require_at_least_one_invoice_per_nonzero_category ?? true,
require_ula_above_threshold: gate.require_ula_above_threshold ?? true, require_ula_above_threshold: gate.require_ula_above_threshold ?? true,
require_all_documents_resolved: gate.require_all_documents_resolved ?? true require_all_documents_resolved: gate.require_all_documents_resolved ?? true,
// v2 multi-tranche + custom_checks
max_tranches: gate.max_tranches ?? 1,
custom_checks: (j.custom_checks || []).map(cc => ({
code: cc.code || '',
label: cc.label || '',
description: cc.description || '',
requires_document: !!cc.requires_document,
required: !!cc.required,
}))
}; };
}; };
@@ -138,8 +148,17 @@ const formToSchemaJson = (f, base = null) => {
amount_basis: f.amount_basis, amount_basis: f.amount_basis,
require_at_least_one_invoice_per_nonzero_category: f.require_invoice_per_category, require_at_least_one_invoice_per_nonzero_category: f.require_invoice_per_category,
require_ula_above_threshold: f.require_ula_above_threshold, require_ula_above_threshold: f.require_ula_above_threshold,
require_all_documents_resolved: f.require_all_documents_resolved require_all_documents_resolved: f.require_all_documents_resolved,
} max_tranches: f.max_tranches || 1
},
custom_checks: (f.custom_checks || []).map(cc => ({
code: cc.code,
label: cc.label,
description: cc.description,
requires_document: !!cc.requires_document,
required: !!cc.required,
})),
schema_version: 2
}; };
}; };
@@ -215,6 +234,20 @@ const BandoRendicontazioneSchemaEdit = () => {
setDirty(true); setDirty(true);
}; };
// v2 custom_checks
const updateCheck = (idx, patch) => {
setForm(p => ({ ...p, custom_checks: p.custom_checks.map((c,i) => i===idx ? {...c, ...patch} : c) }));
setDirty(true);
};
const addCheck = () => {
setForm(p => ({ ...p, custom_checks: [...(p.custom_checks || []), { code:'', label:'', description:'', requires_document:false, required:false }] }));
setDirty(true);
};
const removeCheck = (idx) => {
setForm(p => ({ ...p, custom_checks: p.custom_checks.filter((_,i) => i!==idx) }));
setDirty(true);
};
// ---------- actions ---------- // ---------- actions ----------
const handleInitializeRestart = (e) => { const handleInitializeRestart = (e) => {
confirmPopup({ confirmPopup({
@@ -311,15 +344,25 @@ const BandoRendicontazioneSchemaEdit = () => {
)} )}
{!schemaLoading && !hasSchema && ( {!schemaLoading && !hasSchema && (
<div className="appPageSection" style={{ alignItems: 'center', padding: '3rem 2rem' }}> <div className="appPageSection">
<i className="pi pi-file-edit" style={{ fontSize: '3rem', color: 'var(--text-color-secondary)', marginBottom: '1rem' }} /> <SchemaTemplatePicker
<h2 style={{ marginBottom: '0.5rem' }}>{__('Nessuno schema di rendicontazione per questo bando','gepafin')}</h2> callId={callId}
<p style={{ color: 'var(--text-color-secondary)', marginBottom: '1.5rem', textAlign: 'center' }}> onInitialized={(data) => {
{__('Puoi inizializzarlo con un template predefinito. Per ora è disponibile il template RE-START (fondo prestiti con remissione del debito).','gepafin')} setSchemaRecord(data);
</p> setForm(schemaJsonToForm(data.schema_json));
<Button icon="pi pi-plus-circle" iconPos="right" setDirty(false);
label={__('Inizializza con template RE-START','gepafin')} toast.current?.show({
onClick={handleInitializeRestart} severity="success" /> severity: 'success',
summary: __('Schema inizializzato', 'gepafin'),
detail: __('Puoi ora configurare le sezioni e salvare come bozza.', 'gepafin')
});
}}
onError={(err) => toast.current?.show({
severity: 'error',
summary: __('Inizializzazione fallita', 'gepafin'),
detail: err?.detail || err?.message
})}
/>
</div> </div>
)} )}
@@ -594,6 +637,100 @@ const BandoRendicontazioneSchemaEdit = () => {
<div className="appPage__spacer"></div> <div className="appPage__spacer"></div>
{/* 7 - TRANCHES + CUSTOM CHECKS (v2) */}
<div className="appPageSection">
<h2>{__('7. Tranches di rendicontazione','gepafin')}</h2>
<p style={{ color:'var(--text-color-secondary)', marginTop: 0 }}>
{__('Numero massimo di tranche che il beneficiario puo aprire per questo bando. Il default 1 mantiene il comportamento classico a rendicontazione unica. Aumenta il numero per permettere rendicontazioni multi-fase (es. stati di avanzamento).','gepafin')}
</p>
<div className="appForm__field" style={{maxWidth:'300px'}}>
<label>{__('Tranches massime','gepafin')}</label>
<InputNumber value={form.max_tranches}
onValueChange={(e) => update({max_tranches: e.value})}
min={1} max={20} showButtons disabled={readOnly} />
</div>
</div>
<div className="appPage__spacer"></div>
<div className="appPageSection">
<h2>{__('8. Controlli aggiuntivi (dichiarazioni beneficiario)','gepafin')} <span style={{fontWeight:400, color:'var(--text-color-secondary)', fontSize:'0.9em'}}>({(form.custom_checks || []).length})</span></h2>
<p style={{ color:'var(--text-color-secondary)', marginTop: 0 }}>
{__('Dichiarazioni aggiuntive richieste al beneficiario, oltre ai documenti standard. Ogni controllo puo richiedere o meno un documento allegato e puo essere obbligatorio o opzionale. Esempi: dichiarazione antiriciclaggio (senza doc, obbligatoria), polizza fidejussoria (con doc, opzionale).','gepafin')}
</p>
<div className="fieldsRepeater">
{(form.custom_checks || []).map((c, i) => (
<div key={i} className="fieldsRepeater__panel" style={{ padding:'1rem', border:'1px solid var(--surface-border)', borderRadius:'6px', background:'var(--surface-50)' }}>
<div className="fieldsRepeater__heading" style={{ marginBottom:'0.5rem' }}>
<strong style={{ color:'var(--primary-color)' }}>{c.code || `check #${i+1}`} {c.label || __('(senza etichetta)','gepafin')}</strong>
{!readOnly && (
<Button type="button" icon="pi pi-trash" severity="danger" outlined
size="small" onClick={() => removeCheck(i)}
tooltip={__('Rimuovi controllo','gepafin')} tooltipOptions={{position:'top'}} />
)}
</div>
<div className="appForm__cols">
<div className="appForm__field">
<label>{__('Codice (snake_case)','gepafin')}</label>
<InputText value={c.code}
onChange={(e) => updateCheck(i,{code:e.target.value.toLowerCase().replace(/[^a-z0-9_]/g,'_')})}
placeholder="antiriciclaggio" disabled={readOnly} />
</div>
<div className="appForm__field">
<label>{__('Etichetta visibile','gepafin')}</label>
<InputText value={c.label}
onChange={(e) => updateCheck(i,{label:e.target.value})}
placeholder={__('Dichiarazione antiriciclaggio','gepafin')} disabled={readOnly} />
</div>
</div>
<div className="appForm__field">
<label>{__('Descrizione (testo mostrato al beneficiario)','gepafin')}</label>
<InputTextarea value={c.description}
onChange={(e) => updateCheck(i,{description:e.target.value})}
rows={3} autoResize disabled={readOnly}
placeholder={__('Dichiaro che il beneficiario rispetta...','gepafin')} />
</div>
<div className="appForm__cols">
<div className="appForm__field">
<div className="appForm__row">
<InputSwitch checked={c.requires_document}
onChange={(e) => updateCheck(i,{requires_document:e.value})} disabled={readOnly} />
<label style={{ cursor: 'pointer' }}
onClick={() => !readOnly && updateCheck(i,{requires_document: !c.requires_document})}>
{__('Richiede documento allegato','gepafin')}
</label>
</div>
<small className="text-color-secondary">
{__("Se attivo, il beneficiario puo allegare un PDF (max 15MB).",'gepafin')}
</small>
</div>
<div className="appForm__field">
<div className="appForm__row">
<InputSwitch checked={c.required}
onChange={(e) => updateCheck(i,{required:e.value})} disabled={readOnly} />
<label style={{ cursor: 'pointer' }}
onClick={() => !readOnly && updateCheck(i,{required: !c.required})}>
{__('Obbligatorio','gepafin')}
</label>
</div>
<small className="text-color-secondary">
{__("Se attivo, il beneficiario deve dichiararlo prima di poter inviare la pratica.",'gepafin')}
</small>
</div>
</div>
</div>
))}
</div>
{!readOnly && (
<div style={{ marginTop: '1rem' }}>
<Button type="button" icon="pi pi-plus" iconPos="right" outlined
label={__('Aggiungi controllo aggiuntivo','gepafin')} onClick={addCheck} />
</div>
)}
</div>
<div className="appPage__spacer"></div>
{/* ACTIONS BOTTOM (copia degli action top per comodità) */} {/* ACTIONS BOTTOM (copia degli action top per comodità) */}
{!isPublished && ( {!isPublished && (
<div className="appPageSection"> <div className="appPageSection">

View File

@@ -14,6 +14,8 @@ import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column'; import { Column } from 'primereact/column';
import { Checkbox } from 'primereact/checkbox'; import { Checkbox } from 'primereact/checkbox';
import { ConfirmPopup, confirmPopup } from 'primereact/confirmpopup'; import { ConfirmPopup, confirmPopup } from 'primereact/confirmpopup';
import { Editor } from 'primereact/editor';
import { FileUpload } from 'primereact/fileupload';
import RendicontazioneService from '../service/rendicontazioneService'; import RendicontazioneService from '../service/rendicontazioneService';
import FilePreviewDialog from '../components/FilePreviewDialog'; import FilePreviewDialog from '../components/FilePreviewDialog';
@@ -48,6 +50,7 @@ const VERIFICATION_DOC_TAG = {
}; };
const AMENDMENT_STATUS = { const AMENDMENT_STATUS = {
DRAFT: { severity: 'secondary', label: 'Bozza (non inviata)' },
AWAITING: { severity: 'warning', label: 'Attesa risposta' }, AWAITING: { severity: 'warning', label: 'Attesa risposta' },
RESPONSE_RECEIVED: { severity: 'info', label: 'Risposta ricevuta' }, RESPONSE_RECEIVED: { severity: 'info', label: 'Risposta ricevuta' },
CLOSED: { severity: 'success', label: 'Chiusa' }, CLOSED: { severity: 'success', label: 'Chiusa' },
@@ -71,14 +74,32 @@ const IstruttoriaPratica = () => {
const [previewDialog, setPreviewDialog] = useState({ visible: false, entityType: null, entityId: null, filename: null, title: null }); const [previewDialog, setPreviewDialog] = useState({ visible: false, entityType: null, entityId: null, filename: null, title: null });
const [docNoteDialog, setDocNoteDialog] = useState({ visible: false, doc: null, status: null }); const [docNoteDialog, setDocNoteDialog] = useState({ visible: false, doc: null, status: null });
// tabelle: expanded rows + buffer modifiche inline // tabelle: expanded rows + buffer modifiche inline
const [expandedInv, setExpandedInv] = useState({}); // Array vuoto (NON oggetto): in rowGroupMode='subheader' PrimeReact fa
// findIndex su expandedRows, che fallisce con (collection || []).findIndex is not a function
// se il valore iniziale e {}. La tabella ULA (senza subheader) accetta anche l'oggetto.
const [expandedInv, setExpandedInv] = useState([]);
const [expandedUla, setExpandedUla] = useState({}); const [expandedUla, setExpandedUla] = useState({});
const [invDraft, setInvDraft] = useState({}); // { invoiceId: { amount_verified, notes } } const [invDraft, setInvDraft] = useState({}); // { invoiceId: { amount_verified, notes } }
const [ulaDraft, setUlaDraft] = useState({}); // { employeeId: { fte_pct_verified, notes } } const [ulaDraft, setUlaDraft] = useState({}); // { employeeId: { fte_pct_verified, notes } }
const [approveDialog, setApproveDialog] = useState({ visible: false, amount: null }); const [approveDialog, setApproveDialog] = useState({ visible: false, amount: null });
const [rejectDialog, setRejectDialog] = useState({ visible: false, reason: '' }); const [rejectDialog, setRejectDialog] = useState({ visible: false, reason: '' });
const [amendDialog, setAmendDialog] = useState({ visible: false, text: '', deadline: null }); const [amendDialog, setAmendDialog] = useState({
visible: false, mode: 'create', amendmentId: null,
text: '', deadline: null, response_days: 15, internal_note: '',
instructor_file: null, current_doc_path: null
});
const [extendDialog, setExtendDialog] = useState({ visible: false, amendment: null, extended_days: 7, motivation: '' });
const amendFileRef = useRef(null);
// v2: custom_checks (merge schema+values dal BE)
const [customChecks, setCustomChecks] = useState([]);
const [ccVerifyDialog, setCcVerifyDialog] = useState({ visible: false, cc: null, status: null, notes: '' });
const loadCustomChecks = useCallback(() => {
if (!practiceId) return;
RendicontazioneService.listCustomChecks(practiceId,
(resp) => setCustomChecks(resp?.data?.custom_checks || []),
() => {});
}, [practiceId]);
const load = useCallback(() => { const load = useCallback(() => {
setLoading(true); setLoading(true);
@@ -92,6 +113,7 @@ const IstruttoriaPratica = () => {
}, [practiceId]); }, [practiceId]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
useEffect(() => { loadCustomChecks(); }, [loadCustomChecks]);
const practice = bundle?.practice; const practice = bundle?.practice;
const gate = bundle?.gate_check; const gate = bundle?.gate_check;
@@ -108,8 +130,11 @@ const IstruttoriaPratica = () => {
const raw = s.required_types || []; const raw = s.required_types || [];
return raw.map(r => typeof r === 'string' ? { code: r, label: r } : r); return raw.map(r => typeof r === 'string' ? { code: r, label: r } : r);
}, [sections]); }, [sections]);
const customChecksDefs = useMemo(() => {
return practice?.schema_snapshot?.custom_checks || [];
}, [practice]);
const openAmendments = amendments.filter(a => a.status === 'AWAITING' || a.status === 'RESPONSE_RECEIVED'); const openAmendments = amendments.filter(a => ['DRAFT','AWAITING','RESPONSE_RECEIVED'].includes(a.status));
const isReviewable = practice && ['SUBMITTED', 'UNDER_REVIEW', 'AWAITING_AMENDMENT'].includes(practice.status); const isReviewable = practice && ['SUBMITTED', 'UNDER_REVIEW', 'AWAITING_AMENDMENT'].includes(practice.status);
const isDecidable = practice && ['UNDER_REVIEW', 'AWAITING_AMENDMENT'].includes(practice.status); const isDecidable = practice && ['UNDER_REVIEW', 'AWAITING_AMENDMENT'].includes(practice.status);
const isVerifiable = practice && ['UNDER_REVIEW', 'AWAITING_AMENDMENT'].includes(practice.status); const isVerifiable = practice && ['UNDER_REVIEW', 'AWAITING_AMENDMENT'].includes(practice.status);
@@ -269,6 +294,45 @@ const IstruttoriaPratica = () => {
}, onErr); }, onErr);
}; };
// v2: verify custom_check
const verifyCustomCheckInline = (cc, status, notes) => {
RendicontazioneService.verifyCustomCheck(practiceId, cc.code,
{ verification_status: status, verification_notes: notes || null },
(resp) => {
toast.current?.show({ severity: 'success', summary: __('Controllo aggiornato', 'gepafin') });
loadCustomChecks();
}, onErr);
};
const openCcVerifyDialog = (cc, status) => {
setCcVerifyDialog({ visible: true, cc, status, notes: cc.verification_notes || '' });
};
const confirmCcVerify = () => {
const { cc, status, notes } = ccVerifyDialog;
verifyCustomCheckInline(cc, status, notes);
setCcVerifyDialog({ visible: false, cc: null, status: null, notes: '' });
};
const downloadCustomCheckDoc = (cc) => {
RendicontazioneService.fetchCustomCheckDocumentBlob(practiceId, cc.code, false,
({ objectUrl, filename }) => {
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(objectUrl), 60000);
},
(err) => toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail }));
};
const previewCustomCheckDoc = (cc) => {
RendicontazioneService.fetchCustomCheckDocumentBlob(practiceId, cc.code, true,
({ objectUrl }) => {
const w = window.open(objectUrl, '_blank');
if (w) setTimeout(() => URL.revokeObjectURL(objectUrl), 120000);
},
(err) => toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail }));
};
// Final notes + checklist (debounced inline save) // Final notes + checklist (debounced inline save)
const saveFinalNotes = (patch) => { const saveFinalNotes = (patch) => {
RendicontazioneService.setInstructorFinalNotes(practiceId, patch, afterOk(__('Verbale aggiornato', 'gepafin')), onErr); RendicontazioneService.setInstructorFinalNotes(practiceId, patch, afterOk(__('Verbale aggiornato', 'gepafin')), onErr);
@@ -298,18 +362,123 @@ const IstruttoriaPratica = () => {
RendicontazioneService.rejectPractice(practiceId, rejectDialog.reason, RendicontazioneService.rejectPractice(practiceId, rejectDialog.reason,
(resp) => { setRejectDialog({ visible: false, reason: '' }); afterOk(__('Pratica respinta', 'gepafin'))(resp); }, onErr); (resp) => { setRejectDialog({ visible: false, reason: '' }); afterOk(__('Pratica respinta', 'gepafin'))(resp); }, onErr);
}; };
const doAmend = () => { const _stripHtml = (html) => {
if (!amendDialog.text || amendDialog.text.trim().length < 10) { if (!html) return '';
toast.current?.show({ severity: 'warn', summary: __('Testo troppo corto', 'gepafin') }); return; const tmp = document.createElement('div');
tmp.innerHTML = html;
return (tmp.textContent || tmp.innerText || '').trim();
};
const resetAmendDialog = () => setAmendDialog({
visible: false, mode: 'create', amendmentId: null,
text: '', deadline: null, response_days: 15, internal_note: '',
instructor_file: null, current_doc_path: null
});
const openCreateAmendDialog = () => setAmendDialog({
visible: true, mode: 'create', amendmentId: null,
text: '', deadline: null, response_days: 15, internal_note: '',
instructor_file: null, current_doc_path: null
});
const openEditAmendDialog = (a) => setAmendDialog({
visible: true, mode: 'edit', amendmentId: a.id,
text: a.request_text || '',
deadline: a.deadline ? new Date(a.deadline) : null,
response_days: a.response_days || 15,
internal_note: a.internal_note || '',
instructor_file: null, current_doc_path: a.amendment_document_path || null
});
const doAmend = (sendAfterSave = false) => {
const plainText = _stripHtml(amendDialog.text);
if (plainText.length < 10) {
toast.current?.show({ severity: 'warn', summary: __('Testo troppo corto (min 10 caratteri)', 'gepafin') }); return;
} }
if (!amendDialog.deadline) { if (!amendDialog.deadline) {
toast.current?.show({ severity: 'warn', summary: __('Deadline obbligatoria', 'gepafin') }); return; toast.current?.show({ severity: 'warn', summary: __('Deadline obbligatoria', 'gepafin') }); return;
} }
const body = { request_text: amendDialog.text, const deadlineStr = typeof amendDialog.deadline === 'string'
deadline: typeof amendDialog.deadline === 'string' ? amendDialog.deadline : amendDialog.deadline.toISOString().slice(0, 10) }; ? amendDialog.deadline
RendicontazioneService.createAmendment(practiceId, body, : amendDialog.deadline.toISOString().slice(0, 10);
(resp) => { setAmendDialog({ visible: false, text: '', deadline: null }); afterOk(__('Soccorso avviato', 'gepafin'))(resp); }, onErr); const body = {
request_text: amendDialog.text, deadline: deadlineStr,
response_days: amendDialog.response_days,
internal_note: amendDialog.internal_note || null
};
const uploadIfNeeded = (savedAmendment, then) => {
if (amendDialog.instructor_file) {
RendicontazioneService.uploadAmendmentDocument(practiceId, savedAmendment.id, amendDialog.instructor_file,
() => then(savedAmendment), onErr);
} else { then(savedAmendment); }
};
const finalStep = (savedAmendment) => {
if (sendAfterSave) {
RendicontazioneService.sendAmendment(practiceId, savedAmendment.id,
(resp) => { resetAmendDialog(); afterOk(__('Soccorso inviato al beneficiario', 'gepafin'))(resp); }, onErr);
} else {
resetAmendDialog();
afterOk(__('Bozza salvata', 'gepafin'))({ data: savedAmendment });
}
};
if (amendDialog.mode === 'create') {
RendicontazioneService.createAmendment(practiceId, body,
(resp) => uploadIfNeeded(resp.data, finalStep), onErr);
} else {
RendicontazioneService.updateAmendment(practiceId, amendDialog.amendmentId, body,
(resp) => uploadIfNeeded(resp.data, finalStep), onErr);
}
}; };
const sendDraftAmendment = (ev, a) => {
confirmPopup({
target: ev.currentTarget,
message: __('Inviare il soccorso al beneficiario? Dopo l invio non sara piu modificabile.', 'gepafin'),
icon: 'pi pi-send',
acceptLabel: __('Invia', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'),
accept: () => RendicontazioneService.sendAmendment(practiceId, a.id,
afterOk(__('Soccorso inviato', 'gepafin')), onErr)
});
};
const deleteDraftAmendment = (ev, a) => {
confirmPopup({
target: ev.currentTarget,
message: __('Eliminare questa bozza di soccorso?', 'gepafin'),
icon: 'pi pi-exclamation-triangle',
acceptLabel: __('Elimina', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'),
acceptClassName: 'p-button-danger',
accept: () => RendicontazioneService.deleteAmendment(practiceId, a.id,
afterOk(__('Bozza eliminata', 'gepafin')), onErr)
});
};
const doExtendAmendment = () => {
if (!extendDialog.extended_days || extendDialog.extended_days < 1) {
toast.current?.show({ severity: 'warn', summary: __('Indicare giorni di proroga', 'gepafin') }); return;
}
RendicontazioneService.extendAmendment(practiceId, extendDialog.amendment.id,
extendDialog.extended_days, extendDialog.motivation || null,
(resp) => {
setExtendDialog({ visible: false, amendment: null, extended_days: 7, motivation: '' });
afterOk(__('Scadenza prorogata', 'gepafin'))(resp);
}, onErr);
};
const sendReminder = (ev, a) => {
confirmPopup({
target: ev.currentTarget,
message: __('Inviare un reminder al beneficiario? Il backend accodera una PEC di sollecito.', 'gepafin'),
icon: 'pi pi-bell',
acceptLabel: __('Invia reminder', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'),
accept: () => RendicontazioneService.sendAmendmentReminder(practiceId, a.id,
afterOk(__('Reminder accodato', 'gepafin')), onErr)
});
};
const closeAmendment = (ev, a) => { const closeAmendment = (ev, a) => {
confirmPopup({ confirmPopup({
target: ev.currentTarget, target: ev.currentTarget,
@@ -414,7 +583,9 @@ const IstruttoriaPratica = () => {
<Button type="button" icon="pi pi-comment" iconPos="right" severity="warning" outlined <Button type="button" icon="pi pi-comment" iconPos="right" severity="warning" outlined
label={__('Soccorso istruttorio', 'gepafin')} label={__('Soccorso istruttorio', 'gepafin')}
disabled={openAmendments.length > 0} disabled={openAmendments.length > 0}
onClick={() => setAmendDialog({ visible: true, text: '', deadline: null })} /> tooltip={openAmendments.length > 0 ? __('Soccorso gia aperto su questa pratica', 'gepafin') : null}
tooltipOptions={{ showOnDisabled: true }}
onClick={openCreateAmendDialog} />
</>)} </>)}
{/* Verbale: sempre visibile all'istruttore per preview e scarico */} {/* Verbale: sempre visibile all'istruttore per preview e scarico */}
@@ -513,29 +684,85 @@ const IstruttoriaPratica = () => {
<div className="fieldsRepeater"> <div className="fieldsRepeater">
{amendments.map(a => { {amendments.map(a => {
const cfg = AMENDMENT_STATUS[a.status] || { severity: 'secondary', label: a.status }; const cfg = AMENDMENT_STATUS[a.status] || { severity: 'secondary', label: a.status };
const isDraft = a.status === 'DRAFT';
const isAwaiting = a.status === 'AWAITING';
const isClosable = ['AWAITING','RESPONSE_RECEIVED','EXPIRED'].includes(a.status);
return ( return (
<div key={a.id} className="fieldsRepeater__panel" <div key={a.id} className="fieldsRepeater__panel"
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem', background: 'var(--surface-50)' }}> style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem', background: 'var(--surface-50)', marginBottom: '0.75rem' }}>
<div className="fieldsRepeater__heading" style={{ marginBottom: '0.5rem' }}> <div className="fieldsRepeater__heading" style={{ marginBottom: '0.5rem', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '0.5rem' }}>
<div> <div>
<Tag severity={cfg.severity} value={cfg.label} /> <Tag severity={cfg.severity} value={cfg.label} />
<span style={{ marginLeft: '0.75rem', color: 'var(--text-color-secondary)' }}> <span style={{ marginLeft: '0.75rem', color: 'var(--text-color-secondary)', fontSize: '0.9em' }}>
{__('Deadline:', 'gepafin')} {formatDate(a.deadline)} · {__('Creata:', 'gepafin')} {formatDateTime(a.created_at)} {__('Scadenza:', 'gepafin')} <strong>{formatDate(a.deadline)}</strong>
{a.response_days ? ` (${a.response_days}gg)` : ''}
{a.extended_days ? ` · ${__('prorogato di', 'gepafin')} ${a.extended_days}gg` : ''}
{' · '}{__('Creata:', 'gepafin')} {formatDateTime(a.created_at)}
{a.pec_sent_at ? ` · ${__('PEC inviata', 'gepafin')} ${formatDateTime(a.pec_sent_at)}` : ''}
{a.protocol_id ? ` · ${__('Prot.', 'gepafin')} ${a.protocol_id}` : ''}
</span> </span>
</div> </div>
{a.status !== 'CLOSED' && isReviewable && ( <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<Button icon="pi pi-check" label={__('Chiudi soccorso', 'gepafin')} {isDraft && isReviewable && (<>
size="small" outlined severity="success" <Button icon="pi pi-pencil" label={__('Modifica', 'gepafin')}
onClick={(e) => closeAmendment(e, a)} /> size="small" outlined onClick={() => openEditAmendDialog(a)} />
)} <Button icon="pi pi-send" label={__('Invia al beneficiario', 'gepafin')}
size="small" severity="warning"
onClick={(e) => sendDraftAmendment(e, a)} />
<Button icon="pi pi-trash" label={__('Elimina bozza', 'gepafin')}
size="small" outlined severity="danger"
onClick={(e) => deleteDraftAmendment(e, a)} />
</>)}
{isAwaiting && isReviewable && (<>
<Button icon="pi pi-calendar-plus" label={__('Proroga', 'gepafin')}
size="small" outlined
onClick={() => setExtendDialog({ visible: true, amendment: a, extended_days: 7, motivation: '' })} />
<Button icon="pi pi-bell" label={__('Reminder', 'gepafin')}
size="small" outlined severity="help"
onClick={(e) => sendReminder(e, a)}
disabled={!!a.pec_retry_after}
tooltip={a.pec_retry_after ? __('Reminder gia accodato', 'gepafin') : null}
tooltipOptions={{ showOnDisabled: true }} />
</>)}
{isClosable && isReviewable && (
<Button icon="pi pi-check" label={__('Chiudi soccorso', 'gepafin')}
size="small" outlined severity="success"
onClick={(e) => closeAmendment(e, a)} />
)}
</div>
</div> </div>
<div> <div>
<small className="text-color-secondary">{__('Richiesta istruttore:', 'gepafin')}</small> <small className="text-color-secondary">{__('Richiesta istruttore:', 'gepafin')}</small>
<div style={{ whiteSpace: 'pre-wrap', marginBottom: '0.5rem' }}>{a.request_text}</div> <div style={{ padding: '0.5rem', background: 'white', borderRadius: '4px', marginTop: '0.25rem', marginBottom: '0.5rem' }}
dangerouslySetInnerHTML={{ __html: a.request_text || '' }} />
{a.amendment_document_path && (
<div style={{ marginBottom: '0.5rem' }}>
<i className="pi pi-paperclip" style={{ marginRight: '0.25rem' }} />
<small className="text-color-secondary">{__('Allegato istruttore presente', 'gepafin')}</small>
</div>
)}
{a.internal_note && (
<div style={{ marginTop: '0.5rem', padding: '0.5rem', background: 'var(--yellow-50, #fefce8)', borderLeft: '3px solid var(--yellow-400, #facc15)', borderRadius: '3px' }}>
<small className="text-color-secondary"><i className="pi pi-lock" style={{marginRight:'0.25rem'}} />{__('Nota interna (non visibile al beneficiario):', 'gepafin')}</small>
<div style={{ whiteSpace: 'pre-wrap', fontStyle: 'italic' }}>{a.internal_note}</div>
</div>
)}
{a.response_text && (<> {a.response_text && (<>
<small className="text-color-secondary">{__('Risposta beneficiario', 'gepafin')} ({formatDateTime(a.response_at)}):</small> <small className="text-color-secondary">{__('Risposta beneficiario', 'gepafin')} ({formatDateTime(a.response_at)}):</small>
<div style={{ whiteSpace: 'pre-wrap', padding: '0.5rem', background: 'white', borderRadius: '4px', marginTop: '0.25rem' }}>{a.response_text}</div> <div style={{ whiteSpace: 'pre-wrap', padding: '0.5rem', background: 'white', borderRadius: '4px', marginTop: '0.25rem' }}>{a.response_text}</div>
{a.response_document_path && (
<div style={{ marginTop: '0.25rem' }}>
<i className="pi pi-paperclip" style={{ marginRight: '0.25rem' }} />
<small className="text-color-secondary">{__('Allegato risposta presente', 'gepafin')}</small>
</div>
)}
</>)} </>)}
{a.pec_failed_reason && (
<div style={{ marginTop: '0.5rem', padding: '0.5rem', background: 'var(--red-50, #fef2f2)', borderLeft: '3px solid var(--red-400, #f87171)', borderRadius: '3px' }}>
<small className="text-color-secondary"><i className="pi pi-exclamation-circle" style={{marginRight:'0.25rem', color:'var(--red-600, #dc2626)'}} />{__('Errore invio PEC:', 'gepafin')}</small>
<div style={{ whiteSpace: 'pre-wrap', color: 'var(--red-700, #b91c1c)' }}>{a.pec_failed_reason}</div>
</div>
)}
</div> </div>
</div> </div>
); );
@@ -570,6 +797,7 @@ const IstruttoriaPratica = () => {
} }
return ( return (
<div style={{ width: '100%' }}>
<DataTable <DataTable
value={sortedInvoices} value={sortedInvoices}
dataKey="id" dataKey="id"
@@ -579,6 +807,7 @@ const IstruttoriaPratica = () => {
sortMode="single" sortMode="single"
sortField="category_code" sortField="category_code"
sortOrder={1} sortOrder={1}
style={{ width: '100%' }}
tableStyle={{ minWidth: '1100px', width: '100%' }} tableStyle={{ minWidth: '1100px', width: '100%' }}
expandedRows={expandedInv} expandedRows={expandedInv}
onRowToggle={(e) => setExpandedInv(e.data)} onRowToggle={(e) => setExpandedInv(e.data)}
@@ -721,6 +950,7 @@ const IstruttoriaPratica = () => {
</div> </div>
)} /> )} />
</DataTable> </DataTable>
</div>
); );
})()} })()}
</div> </div>
@@ -944,7 +1174,110 @@ const IstruttoriaPratica = () => {
</ol> </ol>
</div> </div>
{/* VERBALE ISTRUTTORIA */} {/* VERIFICA CONTROLLI AGGIUNTIVI (v2) */}
{customChecksDefs.length > 0 && (<>
<div className="appPage__spacer"></div>
<div className="appPageSection">
<h2>{__('Verifica controlli aggiuntivi', 'gepafin')}</h2>
<p className="text-color-secondary" style={{ marginTop: 0 }}>
{__('Dichiarazioni aggiuntive del beneficiario. Valida ciascun controllo con VALIDO o NON_VALIDO (richiede motivazione). I controlli obbligatori non dichiarati impediscono l\'approvazione.', 'gepafin')}
</p>
<ol className="appPageSection__list">
{customChecksDefs.map(def => {
const val = customChecks.find(c => c.code === def.code) || {};
const stat = val.verification_status || 'PENDING';
const declared = !!val.beneficiary_declared;
const hasDoc = !!val.filename_original;
const missingRequired = def.required && !declared;
const sevMap = {
PENDING: { severity: 'secondary', label: __('Da verificare', 'gepafin') },
VALIDO: { severity: 'success', label: __('Valido', 'gepafin') },
NON_VALIDO: { severity: 'danger', label: __('Non valido', 'gepafin') }
};
const cfg = sevMap[stat] || sevMap.PENDING;
return (
<li key={def.code} className="appPageSection__listItem">
<div className="appPageSection__listItemRow">
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<strong>{def.label}</strong>
<code style={{ fontSize: '0.85em' }}>{def.code}</code>
{def.required && <Tag severity="warning" value={__('Obbligatorio', 'gepafin')} />}
{declared
? <Tag severity="success" value={__('Dichiarato', 'gepafin')} />
: <Tag severity={missingRequired ? 'danger' : 'secondary'} value={__('Non dichiarato', 'gepafin')} />}
<Tag severity={cfg.severity} value={cfg.label} />
</div>
{def.description && (
<div style={{ marginTop: '0.3rem', fontSize: '0.9em', color: 'var(--text-color-secondary)', whiteSpace: 'pre-wrap' }}>
{def.description}
</div>
)}
{def.requires_document && (
<div style={{ marginTop: '0.4rem', fontSize: '0.9em' }}>
{hasDoc ? (
<span>
<i className="pi pi-file-pdf" style={{ color: 'var(--primary-color)', marginRight: '0.3rem' }} />
<strong>{val.filename_original}</strong>
{val.size_bytes && <small className="text-color-secondary"> ({(val.size_bytes/1024).toFixed(1)} KB)</small>}
</span>
) : (
<span className="text-color-secondary">
<i className="pi pi-file" style={{ marginRight: '0.3rem' }} />
{__('Nessun documento allegato', 'gepafin')}
</span>
)}
</div>
)}
{val.verification_notes && (
<div style={{ marginTop: '0.5rem', padding: '0.4rem 0.6rem', background: 'var(--surface-100)', borderLeft: '3px solid var(--orange-400)', fontSize: '0.9em' }}>
<i className="pi pi-pencil" style={{ marginRight: '0.4rem' }} />{val.verification_notes}
</div>
)}
</div>
<div className="appPageSection__iconActions">
<Button icon="pi pi-eye" rounded outlined severity="info"
disabled={!hasDoc}
onClick={() => previewCustomCheckDoc(val)}
tooltip={__('Anteprima', 'gepafin')} tooltipOptions={{ position: 'top' }} />
<Button icon="pi pi-download" rounded outlined severity="info"
disabled={!hasDoc}
onClick={() => downloadCustomCheckDoc(val)}
tooltip={__('Scarica', 'gepafin')} tooltipOptions={{ position: 'top' }} />
<Button icon="pi pi-thumbs-up" rounded outlined
severity={stat === 'VALIDO' ? 'success' : 'secondary'}
disabled={!isVerifiable}
onClick={() => {
if (stat === 'VALIDO') {
verifyCustomCheckInline({ code: def.code }, 'PENDING', null);
} else {
verifyCustomCheckInline({ code: def.code }, 'VALIDO', val.verification_notes);
}
}}
tooltip={stat === 'VALIDO' ? __('Annulla valido', 'gepafin') : __('Valido', 'gepafin')}
tooltipOptions={{ position: 'top' }} />
<Button icon="pi pi-thumbs-down" rounded outlined
severity={stat === 'NON_VALIDO' ? 'danger' : 'secondary'}
disabled={!isVerifiable}
onClick={() => {
if (stat === 'NON_VALIDO') {
verifyCustomCheckInline({ code: def.code }, 'PENDING', null);
} else {
openCcVerifyDialog({ code: def.code, verification_notes: val.verification_notes }, 'NON_VALIDO');
}
}}
tooltip={stat === 'NON_VALIDO' ? __('Annulla non valido', 'gepafin') : __('Non valido', 'gepafin')}
tooltipOptions={{ position: 'top' }} />
</div>
</div>
</li>
);
})}
</ol>
</div>
</>)}
{/* VERBALE ISTRUTTORIA */}
{isVerifiable && (<> {isVerifiable && (<>
<div className="appPage__spacer"></div> <div className="appPage__spacer"></div>
<div className="appPageSection" style={{ background: 'var(--surface-50)', padding: '1.25rem', borderRadius: '6px' }}> <div className="appPageSection" style={{ background: 'var(--surface-50)', padding: '1.25rem', borderRadius: '6px' }}>
@@ -1065,25 +1398,125 @@ const IstruttoriaPratica = () => {
</form> </form>
</Dialog> </Dialog>
{/* DIALOG SOCCORSO */} {/* DIALOG SOCCORSO — creazione/modifica bozza */}
<Dialog visible={amendDialog.visible} style={{ width: '560px' }} <Dialog visible={amendDialog.visible} style={{ width: '720px' }}
header={__('Avvia soccorso istruttorio', 'gepafin')} modal header={amendDialog.mode === 'edit'
onHide={() => setAmendDialog({ visible: false, text: '', deadline: null })}> ? __('Modifica bozza soccorso istruttorio', 'gepafin')
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); doAmend(); }}> : __('Avvia soccorso istruttorio', 'gepafin')}
modal onHide={resetAmendDialog}>
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); doAmend(false); }}>
<div className="appForm__field"> <div className="appForm__field">
<label>{__('Richiesta al beneficiario', 'gepafin')}</label> <label>{__('Richiesta al beneficiario', 'gepafin')} <span style={{color:'var(--red-500)'}}>*</span></label>
<InputTextarea value={amendDialog.text} rows={5} autoResize <Editor value={amendDialog.text} style={{ height: '180px' }}
onChange={(e) => setAmendDialog(d => ({ ...d, text: e.target.value }))} onTextChange={(e) => setAmendDialog(d => ({ ...d, text: e.htmlValue || '' }))}
placeholder={__('Descrivi le integrazioni richieste...', 'gepafin')} /> placeholder={__('Descrivi le integrazioni richieste. Il testo sara riportato nella PEC.', 'gepafin')} />
<small className="text-color-secondary">
{__('Verra incluso nella PEC al beneficiario.', 'gepafin')}
</small>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div className="appForm__field">
<label>{__('Scadenza risposta', 'gepafin')} <span style={{color:'var(--red-500)'}}>*</span></label>
<Calendar value={amendDialog.deadline} dateFormat="dd/mm/yy" showIcon
minDate={new Date()}
onChange={(e) => setAmendDialog(d => ({ ...d, deadline: e.value }))} />
</div>
<div className="appForm__field">
<label>{__('Giorni risposta (informativo)', 'gepafin')}</label>
<InputNumber value={amendDialog.response_days} min={1} max={120}
suffix=" gg" showButtons
onValueChange={(e) => setAmendDialog(d => ({ ...d, response_days: e.value }))} />
<small className="text-color-secondary">{__('Default 15gg', 'gepafin')}</small>
</div>
</div>
<div className="appForm__field">
<label>
<i className="pi pi-lock" style={{marginRight:'0.25rem'}} />
{__('Nota interna (solo istruttori)', 'gepafin')}
</label>
<InputTextarea value={amendDialog.internal_note} rows={2} autoResize
onChange={(e) => setAmendDialog(d => ({ ...d, internal_note: e.target.value }))}
placeholder={__('Es: verificare a 10gg se il benef ha letto', 'gepafin')} />
<small className="text-color-secondary">{__('Non viene inviata al beneficiario', 'gepafin')}</small>
</div>
<div className="appForm__field">
<label>{__('Allegato (opzionale, PDF)', 'gepafin')}</label>
{amendDialog.current_doc_path && !amendDialog.instructor_file && (
<div style={{ padding: '0.5rem', background: 'var(--surface-50)', borderRadius: '4px', marginBottom: '0.5rem' }}>
<i className="pi pi-paperclip" style={{ marginRight: '0.25rem' }} />
<small>{__('Allegato gia caricato', 'gepafin')}</small>
</div>
)}
<FileUpload ref={amendFileRef} mode="basic" auto={false}
chooseLabel={__('Scegli file PDF', 'gepafin')}
accept="application/pdf" maxFileSize={10*1024*1024}
customUpload uploadHandler={() => {}}
onSelect={(e) => setAmendDialog(d => ({ ...d, instructor_file: e.files[0] || null }))} />
<small className="text-color-secondary">{__('Protocollato e allegato alla PEC lato backend', 'gepafin')}</small>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem', marginTop: '1rem', flexWrap: 'wrap' }}>
<Button type="button" outlined severity="secondary" label={__('Annulla', 'gepafin')}
onClick={resetAmendDialog} />
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button type="submit" outlined label={__('Salva bozza', 'gepafin')} icon="pi pi-save" />
<Button type="button" label={__('Salva e invia al beneficiario', 'gepafin')}
icon="pi pi-send" severity="warning"
onClick={() => doAmend(true)} />
</div>
</div>
</form>
</Dialog>
{/* DIALOG PROROGA SCADENZA */}
<Dialog visible={extendDialog.visible} style={{ width: '480px' }}
header={__('Proroga scadenza soccorso', 'gepafin')} modal
onHide={() => setExtendDialog({ visible: false, amendment: null, extended_days: 7, motivation: '' })}>
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); doExtendAmendment(); }}>
{extendDialog.amendment && (
<div style={{ marginBottom: '1rem', padding: '0.5rem', background: 'var(--surface-50)', borderRadius: '4px' }}>
<small className="text-color-secondary">{__('Scadenza attuale:', 'gepafin')} </small>
<strong>{formatDate(extendDialog.amendment.deadline)}</strong>
</div>
)}
<div className="appForm__field">
<label>{__('Giorni da aggiungere', 'gepafin')} <span style={{color:'var(--red-500)'}}>*</span></label>
<InputNumber value={extendDialog.extended_days} min={1} max={60}
suffix=" gg" showButtons
onValueChange={(e) => setExtendDialog(d => ({ ...d, extended_days: e.value || 0 }))} />
</div> </div>
<div className="appForm__field"> <div className="appForm__field">
<label>{__('Scadenza risposta', 'gepafin')}</label> <label>{__('Motivazione (registrata in nota interna)', 'gepafin')}</label>
<Calendar value={amendDialog.deadline} dateFormat="dd/mm/yy" showIcon <InputTextarea value={extendDialog.motivation} rows={3} autoResize
onChange={(e) => setAmendDialog(d => ({ ...d, deadline: e.value }))} /> onChange={(e) => setExtendDialog(d => ({ ...d, motivation: e.target.value }))}
placeholder={__('Es: richiesta benef per impedimento contabile', 'gepafin')} />
</div> </div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setAmendDialog({ visible: false, text: '', deadline: null })} /> <Button type="button" outlined label={__('Annulla', 'gepafin')}
<Button type="submit" label={__('Invia richiesta', 'gepafin')} icon="pi pi-send" severity="warning" /> onClick={() => setExtendDialog({ visible: false, amendment: null, extended_days: 7, motivation: '' })} />
<Button type="submit" label={__('Proroga scadenza', 'gepafin')} icon="pi pi-calendar-plus" />
</div>
</form>
</Dialog>
{/* DIALOG VERIFICA CUSTOM CHECK (motivazione NON_VALIDO) */}
<Dialog visible={ccVerifyDialog.visible} style={{ width: '520px' }}
header={__('Marca controllo come non valido', 'gepafin')} modal
onHide={() => setCcVerifyDialog({ visible: false, cc: null, status: null, notes: '' })}>
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); confirmCcVerify(); }}>
<div className="appForm__field">
<label>{__('Motivazione (obbligatoria)', 'gepafin')}</label>
<InputTextarea value={ccVerifyDialog.notes} rows={4} autoResize
onChange={(e) => setCcVerifyDialog(d => ({ ...d, notes: e.target.value }))}
placeholder={__('Es: dichiarazione non coerente con il bando...', 'gepafin')} />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
<Button type="button" outlined label={__('Annulla', 'gepafin')}
onClick={() => setCcVerifyDialog({ visible: false, cc: null, status: null, notes: '' })} />
<Button type="submit" label={__('Conferma', 'gepafin')} icon="pi pi-times" severity="danger"
disabled={!ccVerifyDialog.notes || ccVerifyDialog.notes.trim().length < 5} />
</div> </div>
</form> </form>
</Dialog> </Dialog>

View File

@@ -8,8 +8,12 @@ import { Column } from 'primereact/column';
import { Tag } from 'primereact/tag'; import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast'; import { Toast } from 'primereact/toast';
import { Skeleton } from 'primereact/skeleton'; import { Skeleton } from 'primereact/skeleton';
import { Dialog } from 'primereact/dialog';
import { Dropdown } from 'primereact/dropdown';
import { InputTextarea } from 'primereact/inputtextarea';
import RendicontazioneService from '../service/rendicontazioneService'; import RendicontazioneService from '../service/rendicontazioneService';
import { storeGet } from '../../../store';
const STATUS_TAGS = { const STATUS_TAGS = {
SUBMITTED: { severity: 'info', label: 'Da prendere in carico' }, SUBMITTED: { severity: 'info', label: 'Da prendere in carico' },
@@ -24,15 +28,34 @@ const IstruttoriaQueue = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const toast = useRef(null); const toast = useRef(null);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [isManager, setIsManager] = useState(false); const [isManagerFromQueue, setIsManagerFromQueue] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const load = () => { // v2 manager view
// Default: manager/superadmin partono in vista 'tutte le pratiche' (managerMode=true).
// Pre-instructor vedono solo la coda attiva (managerMode=false).
const [managerMode, setManagerMode] = useState(
storeGet('getRole') === 'ROLE_INSTRUCTOR_MANAGER' ||
storeGet('getRole') === 'ROLE_SUPER_ADMIN'
); // toggle UI
const [managerItems, setManagerItems] = useState([]);
const [instructors, setInstructors] = useState([]);
const [reassignDialog, setReassignDialog] = useState({ visible: false, practice: null, newInstructorId: null, reason: '' });
const [reassigning, setReassigning] = useState(false);
// Controllo ruolo utente per mostrare toggle manager.
// storeGet('getRole') ritorna userData.role.roleType (es. ROLE_INSTRUCTOR_MANAGER).
// Fix: il codice precedente usava storeGet('getUser') che non esiste nel selectors,
// quindi canUseManagerView era sempre false e il toggle non compariva mai.
const userRole = storeGet('getRole');
const canUseManagerView = userRole === 'ROLE_INSTRUCTOR_MANAGER' || userRole === 'ROLE_SUPER_ADMIN';
const loadQueue = () => {
setLoading(true); setLoading(true);
RendicontazioneService.instructorQueue( RendicontazioneService.instructorQueue(
(resp) => { (resp) => {
setItems(resp?.data?.items || []); setItems(resp?.data?.items || []);
setIsManager(!!resp?.data?.manager_view); setIsManagerFromQueue(!!resp?.data?.manager_view);
setLoading(false); setLoading(false);
}, },
(err) => { (err) => {
@@ -42,8 +65,66 @@ const IstruttoriaQueue = () => {
); );
}; };
useEffect(() => { load(); }, []); const loadManagerAssignments = () => {
setLoading(true);
RendicontazioneService.managerAssignments(
(resp) => {
setManagerItems(resp?.data?.assignments || []);
setLoading(false);
},
(err) => {
toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail });
setLoading(false);
}
);
};
const loadInstructors = () => {
RendicontazioneService.managerInstructorsList(
(resp) => setInstructors(resp?.data?.instructors || []),
() => {}
);
};
useEffect(() => {
if (managerMode) {
loadManagerAssignments();
if (instructors.length === 0) loadInstructors();
} else {
loadQueue();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [managerMode]);
const openReassign = (row) => {
if (instructors.length === 0) loadInstructors();
setReassignDialog({
visible: true,
practice: row,
newInstructorId: row.assigned_instructor_id || null,
reason: ''
});
};
const confirmReassign = () => {
const { practice, newInstructorId, reason } = reassignDialog;
setReassigning(true);
RendicontazioneService.reassignInstructor(
practice.id, newInstructorId, reason,
(resp) => {
setReassigning(false);
setReassignDialog({ visible: false, practice: null, newInstructorId: null, reason: '' });
toast.current?.show({ severity: 'success', summary: resp?.message || __('Pratica riassegnata', 'gepafin') });
loadManagerAssignments();
},
(err) => {
setReassigning(false);
toast.current?.show({ severity: 'error', summary: __('Riassegnazione fallita', 'gepafin'), detail: err?.detail });
}
);
};
// ---------- templates ----------
const callTpl = (row) => ( const callTpl = (row) => (
<div> <div>
<strong>{row.call_name || `Bando #${row.call_id}`}</strong> <strong>{row.call_name || `Bando #${row.call_id}`}</strong>
@@ -68,7 +149,7 @@ const IstruttoriaQueue = () => {
: <span className="text-color-secondary"></span>; : <span className="text-color-secondary"></span>;
const progressTpl = (row) => ( const progressTpl = (row) => (
<small className="text-color-secondary"> <small className="text-color-secondary">
{row.invoice_count} {__('fatt.','gepafin')} · {row.ula_count} {__('dip.','gepafin')} · {row.document_count} {__('doc','gepafin')} {row.invoice_count} {__('fatt.', 'gepafin')} · {row.ula_count} {__('dip.', 'gepafin')} · {row.document_count} {__('doc', 'gepafin')}
</small> </small>
); );
const actionsTpl = (row) => { const actionsTpl = (row) => {
@@ -82,6 +163,35 @@ const IstruttoriaQueue = () => {
return <span>#{row.assigned_instructor_id}</span>; return <span>#{row.assigned_instructor_id}</span>;
}; };
// Manager view templates
const mgrCallTpl = (row) => (
<div>
<strong>{row.call_name || `Bando #${row.call_id}`}</strong>
<div><small className="text-color-secondary">{row.company_name} · pratica #{row.application_id} · <strong>T{row.sequence_number}</strong>{row.period_label ? ` ${row.period_label}` : ''}</small></div>
</div>
);
const mgrSuggestedTpl = (row) => (
row.suggested_instructor_id
? <div><strong>{row.suggested_instructor_name || `#${row.suggested_instructor_id}`}</strong></div>
: <span className="text-color-secondary">{__('nessuno', 'gepafin')}</span>
);
const mgrAssignedTpl = (row) => {
if (row.is_unassigned) {
return <Tag severity="warning" value={__('Da assegnare', 'gepafin')} icon="pi pi-exclamation-triangle" />;
}
return <div><strong>{row.assigned_instructor_name || `#${row.assigned_instructor_id}`}</strong></div>;
};
const mgrActionsTpl = (row) => (
<div style={{ display: 'flex', gap: '0.4rem' }}>
<Button icon="pi pi-eye" size="small" outlined
label={__('Apri', 'gepafin')}
onClick={() => navigate(`/istruttoria/${row.id}`)} />
<Button icon="pi pi-user-edit" size="small" severity="warning"
label={row.is_unassigned ? __('Assegna', 'gepafin') : __('Riassegna', 'gepafin')}
onClick={() => openReassign(row)} />
</div>
);
return ( return (
<div className="appPage"> <div className="appPage">
<Toast ref={toast} /> <Toast ref={toast} />
@@ -89,35 +199,136 @@ const IstruttoriaQueue = () => {
<div className="appPage__pageHeader"> <div className="appPage__pageHeader">
<h1>{__('Coda istruttoria', 'gepafin')}</h1> <h1>{__('Coda istruttoria', 'gepafin')}</h1>
<p> <p>
{isManager {managerMode
? __('Vista manager: vedi tutte le pratiche in carico a tutti gli istruttori.', 'gepafin') ? __('Vista manager: tutte le pratiche inviate con istruttore suggerito e assegnato. Puoi riassegnare le pratiche da qui.', 'gepafin')
: __('Pool di pratiche da prendere in carico + pratiche assegnate a te.', 'gepafin')} : (isManagerFromQueue
? __('Vista manager: vedi tutte le pratiche in carico a tutti gli istruttori.', 'gepafin')
: __('Pool di pratiche da prendere in carico + pratiche assegnate a te.', 'gepafin'))}
</p> </p>
</div> </div>
<div className="appPage__spacer"></div> <div className="appPage__spacer"></div>
<div className="appPageSection"> {/* TOGGLE MANAGER VIEW */}
{loading && <Skeleton width="100%" height="10rem" />} {canUseManagerView && (
{!loading && items.length === 0 && ( <div className="appPageSection">
<div style={{ padding: '2rem', textAlign: 'center', width: '100%' }}> <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<i className="pi pi-inbox" style={{ fontSize: '2.5rem', color: 'var(--text-color-secondary)', display: 'block', marginBottom: '0.75rem' }} /> <Button icon="pi pi-list"
<p>{__('Nessuna pratica in coda al momento.', 'gepafin')}</p> label={__('Coda standard', 'gepafin')}
outlined={managerMode}
severity={managerMode ? 'secondary' : 'info'}
onClick={() => setManagerMode(false)} />
<Button icon="pi pi-users"
label={__('Vista manager (riassegnazioni)', 'gepafin')}
outlined={!managerMode}
severity={!managerMode ? 'secondary' : 'warning'}
onClick={() => setManagerMode(true)} />
</div>
</div>
)}
<div className="appPage__spacer"></div>
{/* CODA STANDARD */}
{!managerMode && (
<div className="appPageSection">
{loading && <Skeleton width="100%" height="10rem" />}
{!loading && items.length === 0 && (
<div style={{ padding: '2rem', textAlign: 'center', width: '100%' }}>
<i className="pi pi-inbox" style={{ fontSize: '2.5rem', color: 'var(--text-color-secondary)', display: 'block', marginBottom: '0.75rem' }} />
<p>{__('Nessuna pratica in coda al momento.', 'gepafin')}</p>
</div>
)}
{!loading && items.length > 0 && (
<DataTable value={items} dataKey="id" stripedRows responsiveLayout="scroll" style={{ width: '100%' }}>
<Column header={__('Bando / Azienda', 'gepafin')} body={callTpl} />
<Column header={__('Inviata il', 'gepafin')} body={submittedTpl} style={{ width: '140px' }} />
<Column header={__('Stato', 'gepafin')} body={statusTpl} style={{ width: '180px' }} />
<Column header={__('Istruttore', 'gepafin')} body={assignedTpl} style={{ width: '100px' }} />
<Column header={__('Erogato', 'gepafin')} body={erogatoTpl} style={{ width: '130px' }} />
<Column header={__('Remissione', 'gepafin')} body={remissionTpl} style={{ width: '140px' }} />
<Column header={__('Contenuto', 'gepafin')} body={progressTpl} />
<Column header={__('Azione', 'gepafin')} body={actionsTpl} style={{ width: '220px' }} />
</DataTable>
)}
</div>
)}
{/* VISTA MANAGER */}
{managerMode && (
<div className="appPageSection">
{loading && <Skeleton width="100%" height="10rem" />}
{!loading && managerItems.length === 0 && (
<div style={{ padding: '2rem', textAlign: 'center', width: '100%' }}>
<i className="pi pi-check-circle" style={{ fontSize: '2.5rem', color: 'var(--green-500)', display: 'block', marginBottom: '0.75rem' }} />
<p>{__('Nessuna pratica attiva da gestire.', 'gepafin')}</p>
</div>
)}
{!loading && managerItems.length > 0 && (
<DataTable value={managerItems} dataKey="id" stripedRows responsiveLayout="scroll" style={{ width: '100%' }}>
<Column header={__('Bando / Pratica / Tranche', 'gepafin')} body={mgrCallTpl} />
<Column header={__('Inviata il', 'gepafin')} body={submittedTpl} style={{ width: '140px' }} />
<Column header={__('Stato', 'gepafin')} body={statusTpl} style={{ width: '170px' }} />
<Column header={__('Istruttore domanda', 'gepafin')} body={mgrSuggestedTpl} style={{ width: '180px' }} />
<Column header={__('Assegnato a', 'gepafin')} body={mgrAssignedTpl} style={{ width: '200px' }} />
<Column header={__('Erogato', 'gepafin')} body={erogatoTpl} style={{ width: '120px' }} />
<Column header={__('Azioni', 'gepafin')} body={mgrActionsTpl} style={{ width: '260px' }} />
</DataTable>
)}
</div>
)}
{/* DIALOG RIASSEGNA */}
<Dialog header={__('Riassegna pratica', 'gepafin')}
visible={reassignDialog.visible} style={{ width: '520px' }}
onHide={() => !reassigning && setReassignDialog({ visible: false, practice: null, newInstructorId: null, reason: '' })}
modal
footer={(
<div>
<Button label={__('Annulla', 'gepafin')} icon="pi pi-times"
onClick={() => setReassignDialog({ visible: false, practice: null, newInstructorId: null, reason: '' })}
outlined disabled={reassigning} />
<Button label={__('Conferma', 'gepafin')} icon="pi pi-check" iconPos="right"
severity="warning" loading={reassigning} onClick={confirmReassign} />
</div>
)}>
{reassignDialog.practice && (
<div>
<p style={{ marginTop: 0 }}>
<strong>{reassignDialog.practice.call_name}</strong> pratica #{reassignDialog.practice.application_id}
{' '}T{reassignDialog.practice.sequence_number}
{reassignDialog.practice.period_label && `${reassignDialog.practice.period_label}`}
</p>
<p style={{ color: 'var(--text-color-secondary)' }}>
{__('Istruttore domanda', 'gepafin')}: <strong>{reassignDialog.practice.suggested_instructor_name || __('nessuno', 'gepafin')}</strong>
<br />
{__('Attualmente assegnato a', 'gepafin')}: <strong>{reassignDialog.practice.assigned_instructor_name || __('nessuno', 'gepafin')}</strong>
</p>
<div className="appForm__field">
<label>{__('Nuovo istruttore', 'gepafin')}</label>
<Dropdown value={reassignDialog.newInstructorId}
options={[
{ user_id: null, display_name: __('— Metti in coda (nessuno) —', 'gepafin') },
...instructors
]}
optionLabel="display_name" optionValue="user_id"
onChange={(e) => setReassignDialog(d => ({ ...d, newInstructorId: e.value }))}
disabled={reassigning}
placeholder={__('Seleziona istruttore', 'gepafin')} />
<small className="text-color-secondary">
{__('Se la pratica era in SUBMITTED e assegni a qualcuno, passa automaticamente a IN LAVORAZIONE.', 'gepafin')}
</small>
</div>
<div className="appForm__field" style={{ marginTop: '1rem' }}>
<label>{__('Motivazione (opzionale, audit log)', 'gepafin')}</label>
<InputTextarea value={reassignDialog.reason} rows={3} autoResize
onChange={(e) => setReassignDialog(d => ({ ...d, reason: e.target.value }))}
placeholder={__('Es: carico di lavoro, competenza specifica, assenza istruttore...', 'gepafin')}
disabled={reassigning} />
</div>
</div> </div>
)} )}
{!loading && items.length > 0 && ( </Dialog>
<DataTable value={items} dataKey="id" stripedRows responsiveLayout="scroll" style={{ width: '100%' }}>
<Column header={__('Bando / Azienda', 'gepafin')} body={callTpl} />
<Column header={__('Inviata il', 'gepafin')} body={submittedTpl} style={{ width: '140px' }} />
<Column header={__('Stato', 'gepafin')} body={statusTpl} style={{ width: '180px' }} />
<Column header={__('Istruttore', 'gepafin')} body={assignedTpl} style={{ width: '100px' }} />
<Column header={__('Erogato', 'gepafin')} body={erogatoTpl} style={{ width: '130px' }} />
<Column header={__('Remissione', 'gepafin')} body={remissionTpl} style={{ width: '140px' }} />
<Column header={__('Contenuto', 'gepafin')} body={progressTpl} />
<Column header={__('Azione', 'gepafin')} body={actionsTpl} style={{ width: '220px' }} />
</DataTable>
)}
</div>
</div> </div>
); );
}; };

View File

@@ -14,6 +14,8 @@ import { InputNumber } from 'primereact/inputnumber';
import { Dropdown } from 'primereact/dropdown'; import { Dropdown } from 'primereact/dropdown';
import { Calendar } from 'primereact/calendar'; import { Calendar } from 'primereact/calendar';
import { InputTextarea } from 'primereact/inputtextarea'; import { InputTextarea } from 'primereact/inputtextarea';
import { Editor } from 'primereact/editor';
import { FileUpload } from 'primereact/fileupload';
import { DataTable } from 'primereact/datatable'; import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column'; import { Column } from 'primereact/column';
@@ -21,6 +23,7 @@ import { Column } from 'primereact/column';
import RendicontazioneService from '../service/rendicontazioneService'; import RendicontazioneService from '../service/rendicontazioneService';
import FileUploadCell from '../components/FileUploadCell'; import FileUploadCell from '../components/FileUploadCell';
import FilePreviewDialog from '../components/FilePreviewDialog'; import FilePreviewDialog from '../components/FilePreviewDialog';
import CompanyDocumentPicker from '../components/CompanyDocumentPicker';
// ---------- costanti ---------- // ---------- costanti ----------
const IVA_REGIME_LABELS = { const IVA_REGIME_LABELS = {
@@ -76,13 +79,22 @@ const PraticaRendicontazioneEdit = () => {
const [practice, setPractice] = useState(null); const [practice, setPractice] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [gate, setGate] = useState(null); const [gate, setGate] = useState(null);
const [customChecks, setCustomChecks] = useState([]); // v2: merge schema+values dal BE
const loadCustomChecks = useCallback(() => {
if (!practiceId) return;
RendicontazioneService.listCustomChecks(practiceId,
(resp) => setCustomChecks(resp?.data?.custom_checks || []),
() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [practiceId]);
// modal fattura // modal fattura
const [invDialog, setInvDialog] = useState({ visible: false, data: null }); const [invDialog, setInvDialog] = useState({ visible: false, data: null });
// modal dipendente ULA // modal dipendente ULA
const [empDialog, setEmpDialog] = useState({ visible: false, data: null }); const [empDialog, setEmpDialog] = useState({ visible: false, data: null });
// modal risposta soccorso istruttorio // modal risposta soccorso istruttorio
const [amendDialog, setAmendDialog] = useState({ visible: false, amendment: null, responseText: '' }); const [amendDialog, setAmendDialog] = useState({ visible: false, amendment: null, responseText: '', response_file: null });
const responseFileRef = useRef(null);
// preview file // preview file
const [previewDialog, setPreviewDialog] = useState({ visible: false, entityType: null, entityId: null, filename: null, title: null }); const [previewDialog, setPreviewDialog] = useState({ visible: false, entityType: null, entityId: null, filename: null, title: null });
const openPreview = (entityType, entityId, title, filename) => setPreviewDialog({ visible: true, entityType, entityId, title, filename }); const openPreview = (entityType, entityId, title, filename) => setPreviewDialog({ visible: true, entityType, entityId, title, filename });
@@ -135,6 +147,44 @@ const PraticaRendicontazioneEdit = () => {
); );
}; };
// --- CompanyDocument picker state + handlers ---
const [repoPicker, setRepoPicker] = useState({ visible: false, docCode: null });
const openRepositoryPicker = (docCode) => setRepoPicker({ visible: true, docCode });
const closeRepositoryPicker = () => setRepoPicker({ visible: false, docCode: null });
// quando l'utente sceglie un doc dal picker: ensure record -> link-from-repository -> update state
const handleRepositoryPick = (companyDoc) => {
const docCode = repoPicker.docCode;
if (!docCode || !companyDoc) return;
ensureDocRecord(docCode, (remDocId) => {
RendicontazioneService.linkDocumentFromRepository(
remDocId, companyDoc.id,
(resp) => {
const d = resp?.data || {};
setPractice(p => p ? {
...p,
documents: p.documents.map(x => x.doc_code === docCode ? {
...x,
filename: d.filename ?? companyDoc.fileName,
expires_at: d.expires_at ?? null,
source_company_document_id: d.source_company_document_id ?? companyDoc.id,
source_status: d.source_status ?? companyDoc.status,
size_bytes: null,
} : x)
} : p);
const sev = (d.source_status === 'EXPIRED') ? 'warn' : 'success';
toast.current?.show({
severity: sev,
summary: __('Documento collegato dal repository', 'gepafin'),
detail: companyDoc.fileName + ' · ' + (d.source_status || companyDoc.status)
});
},
(err) => toast.current?.show({ severity: 'error', summary: __('Errore link repository', 'gepafin'), detail: err?.detail })
);
});
};
// ---------- load ---------- // ---------- load ----------
const load = useCallback(() => { const load = useCallback(() => {
setLoading(true); setLoading(true);
@@ -155,6 +205,7 @@ const PraticaRendicontazioneEdit = () => {
}; };
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
useEffect(() => { loadCustomChecks(); }, [loadCustomChecks]);
const readOnly = practice && practice.status !== 'DRAFT'; const readOnly = practice && practice.status !== 'DRAFT';
@@ -170,6 +221,10 @@ const PraticaRendicontazioneEdit = () => {
const raw = docsSection.required_types || []; const raw = docsSection.required_types || [];
return raw.map(r => typeof r === 'string' ? { code: r, label: r } : r); return raw.map(r => typeof r === 'string' ? { code: r, label: r } : r);
}, [docsSection]); }, [docsSection]);
// v2: custom_checks definition + values (state separato con fetch dedicato)
const customChecksDefs = useMemo(() => {
return practice?.schema_snapshot?.custom_checks || [];
}, [practice]);
const ivaAllowed = useMemo(() => { const ivaAllowed = useMemo(() => {
const gen = sections.find(x => x.type === 'static_fields'); const gen = sections.find(x => x.type === 'static_fields');
const ivaField = (gen?.fields || []).find(f => f.id === 'iva_regime'); const ivaField = (gen?.fields || []).find(f => f.id === 'iva_regime');
@@ -253,6 +308,18 @@ const PraticaRendicontazioneEdit = () => {
}); });
}; };
// v2: custom_checks
const declareCustomCheck = (code, declared, file) => {
RendicontazioneService.declareCustomCheck(practiceId, code, declared, file,
(resp) => { toast.current?.show({ severity: 'success', summary: __('Controllo aggiornato','gepafin') }); loadCustomChecks(); },
onMutationError);
};
const deleteCustomCheckDoc = (code) => {
RendicontazioneService.deleteCustomCheckDocument(practiceId, code,
(resp) => { toast.current?.show({ severity: 'success', summary: __('Documento rimosso','gepafin') }); loadCustomChecks(); },
onMutationError);
};
// documents // documents
const upsertDocument = (docCode, filename) => { const upsertDocument = (docCode, filename) => {
RendicontazioneService.upsertDocument(practiceId, docCode, { doc_code: docCode, filename }, RendicontazioneService.upsertDocument(practiceId, docCode, { doc_code: docCode, filename },
@@ -280,15 +347,46 @@ const PraticaRendicontazioneEdit = () => {
}); });
}; };
const _stripHtmlBenef = (html) => {
if (!html) return '';
const tmp = document.createElement('div');
tmp.innerHTML = html;
return (tmp.textContent || tmp.innerText || '').trim();
};
const submitAmendmentResponse = () => { const submitAmendmentResponse = () => {
if (!amendDialog.responseText || amendDialog.responseText.trim().length < 5) { const plainText = _stripHtmlBenef(amendDialog.responseText);
if (plainText.length < 5) {
toast.current?.show({ severity: 'warn', summary: __('Risposta troppo corta', 'gepafin') }); toast.current?.show({ severity: 'warn', summary: __('Risposta troppo corta', 'gepafin') });
return; return;
} }
const fileToUpload = amendDialog.response_file;
const amendmentId = amendDialog.amendment.id;
RendicontazioneService.respondAmendmentBeneficiary( RendicontazioneService.respondAmendmentBeneficiary(
practiceId, amendDialog.amendment.id, amendDialog.responseText, practiceId, amendmentId, amendDialog.responseText,
(resp) => { setAmendDialog({ visible: false, amendment: null, responseText: '' }); (resp) => {
afterMutation(__('Risposta inviata all\'istruttore', 'gepafin'))(resp); }, if (fileToUpload) {
RendicontazioneService.uploadResponseDocument(practiceId, amendmentId, fileToUpload,
() => {
setAmendDialog({ visible: false, amendment: null, responseText: '', response_file: null });
afterMutation(__('Risposta trasmessa con allegato', 'gepafin'))(resp);
},
(err) => {
// testo salvato ma upload fallito — avviso e ricarico
setAmendDialog({ visible: false, amendment: null, responseText: '', response_file: null });
toast.current?.show({
severity: 'warn',
summary: __('Risposta salvata, upload allegato fallito', 'gepafin'),
detail: err?.message || ''
});
afterMutation(null)(resp);
});
} else {
setAmendDialog({ visible: false, amendment: null, responseText: '', response_file: null });
afterMutation(__('Risposta inviata all\'istruttore', 'gepafin'))(resp);
}
},
onMutationError); onMutationError);
}; };
@@ -390,8 +488,8 @@ const PraticaRendicontazioneEdit = () => {
</div> </div>
)} )}
{/* SOCCORSO ISTRUTTORIO (se presente) */} {/* SOCCORSO ISTRUTTORIO (se presente — esclude DRAFT, visibile solo quando PEC partita) */}
{practice.amendments && practice.amendments.length > 0 && (<> {practice.amendments && practice.amendments.filter(a => a.status !== 'DRAFT').length > 0 && (<>
<div className="appPage__spacer"></div> <div className="appPage__spacer"></div>
<div className="appPageSection"> <div className="appPageSection">
<h2>{__('Richieste di soccorso istruttorio', 'gepafin')}</h2> <h2>{__('Richieste di soccorso istruttorio', 'gepafin')}</h2>
@@ -399,7 +497,7 @@ const PraticaRendicontazioneEdit = () => {
{__('L\'istruttore ha chiesto integrazioni o chiarimenti. Rispondi al più presto.', 'gepafin')} {__('L\'istruttore ha chiesto integrazioni o chiarimenti. Rispondi al più presto.', 'gepafin')}
</p> </p>
<div className="fieldsRepeater"> <div className="fieldsRepeater">
{practice.amendments.map(a => { {practice.amendments.filter(a => a.status !== 'DRAFT').map(a => {
const statusCfg = { const statusCfg = {
AWAITING: { sev: 'warning', label: 'In attesa della tua risposta' }, AWAITING: { sev: 'warning', label: 'In attesa della tua risposta' },
RESPONSE_RECEIVED: { sev: 'info', label: 'Risposta inviata, in attesa di chiusura' }, RESPONSE_RECEIVED: { sev: 'info', label: 'Risposta inviata, in attesa di chiusura' },
@@ -409,25 +507,41 @@ const PraticaRendicontazioneEdit = () => {
return ( return (
<div key={a.id} className="fieldsRepeater__panel" <div key={a.id} className="fieldsRepeater__panel"
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem', style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem',
background: a.status === 'AWAITING' ? 'var(--orange-50)' : 'var(--surface-50)' }}> background: a.status === 'AWAITING' ? 'var(--orange-50, #fff7ed)' : 'var(--surface-50)',
<div className="fieldsRepeater__heading" style={{ marginBottom: '0.5rem' }}> marginBottom: '0.75rem' }}>
<div className="fieldsRepeater__heading" style={{ marginBottom: '0.5rem', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '0.5rem' }}>
<div> <div>
<Tag severity={statusCfg.sev} value={statusCfg.label} /> <Tag severity={statusCfg.sev} value={statusCfg.label} />
<span style={{ marginLeft: '0.75rem', color: 'var(--text-color-secondary)' }}> <span style={{ marginLeft: '0.75rem', color: 'var(--text-color-secondary)', fontSize: '0.9em' }}>
{__('Scadenza:', 'gepafin')} {new Date(a.deadline).toLocaleDateString('it-IT')} {__('Scadenza:', 'gepafin')} <strong>{new Date(a.deadline).toLocaleDateString('it-IT')}</strong>
{a.response_days ? ` (${a.response_days}gg dalla richiesta)` : ''}
</span> </span>
</div> </div>
{a.status === 'AWAITING' && ( {a.status === 'AWAITING' && (
<Button icon="pi pi-reply" label={__('Rispondi', 'gepafin')} size="small" severity="warning" <Button icon="pi pi-reply" label={__('Rispondi', 'gepafin')} size="small" severity="warning"
onClick={() => setAmendDialog({ visible: true, amendment: a, responseText: '' })} /> onClick={() => setAmendDialog({ visible: true, amendment: a, responseText: '', response_file: null })} />
)} )}
</div> </div>
<div> <div>
<small className="text-color-secondary">{__('Richiesta istruttore:', 'gepafin')}</small> <small className="text-color-secondary">{__('Richiesta istruttore:', 'gepafin')}</small>
<div style={{ whiteSpace: 'pre-wrap', marginBottom: '0.5rem' }}>{a.request_text}</div> <div style={{ padding: '0.5rem', background: 'white', borderRadius: '4px', marginTop: '0.25rem', marginBottom: '0.5rem' }}
dangerouslySetInnerHTML={{ __html: a.request_text || '' }} />
{a.amendment_document_path && (
<div style={{ marginBottom: '0.5rem' }}>
<i className="pi pi-paperclip" style={{ marginRight: '0.25rem' }} />
<small className="text-color-secondary">{__('Allegato istruttore presente (disponibile via PEC)', 'gepafin')}</small>
</div>
)}
{a.response_text && (<> {a.response_text && (<>
<small className="text-color-secondary">{__('Tua risposta:', 'gepafin')}</small> <small className="text-color-secondary">{__('Tua risposta:', 'gepafin')}</small>
<div style={{ whiteSpace: 'pre-wrap', padding: '0.5rem', background: 'white', borderRadius: '4px', marginTop: '0.25rem' }}>{a.response_text}</div> <div style={{ padding: '0.5rem', background: 'white', borderRadius: '4px', marginTop: '0.25rem' }}
dangerouslySetInnerHTML={{ __html: a.response_text }} />
{a.response_document_path && (
<div style={{ marginTop: '0.25rem' }}>
<i className="pi pi-paperclip" style={{ marginRight: '0.25rem' }} />
<small className="text-color-secondary">{__('Allegato inviato con la risposta', 'gepafin')}</small>
</div>
)}
</>)} </>)}
</div> </div>
</div> </div>
@@ -592,7 +706,43 @@ const PraticaRendicontazioneEdit = () => {
<div><small className="text-color-secondary"><code>{dr.code}</code></small></div> <div><small className="text-color-secondary"><code>{dr.code}</code></small></div>
</div> </div>
<div style={{ flex: 2, minWidth: '260px' }}> <div style={{ flex: 2, minWidth: '260px' }}>
{existing && existing.id ? ( {existing && existing.id && existing.filename && existing.source_company_document_id ? (
// CASO A: documento linkato dal repository company — layout custom
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'nowrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flex: 1, minWidth: 0 }}>
<i className="pi pi-file-pdf" style={{ color: 'var(--primary-color)', fontSize: '1.1rem', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: 500 }}>
{existing.filename}
</span>
</div>
<Tag
severity={existing.source_status === 'VALID' ? 'success'
: existing.source_status === 'DUE' ? 'warning'
: existing.source_status === 'EXPIRED' ? 'danger' : 'info'}
icon={existing.source_status === 'VALID' ? 'pi pi-check-circle'
: existing.source_status === 'DUE' ? 'pi pi-exclamation-triangle'
: existing.source_status === 'EXPIRED' ? 'pi pi-times-circle' : 'pi pi-link'}
value={existing.source_status === 'EXPIRED' ? __('Scaduto', 'gepafin')
: existing.source_status === 'DUE' ? __('In scadenza', 'gepafin')
: __('Dal repository', 'gepafin')}
style={{ flexShrink: 0 }}
/>
{!readOnly && (
<>
<Button type="button" icon="pi pi-pencil" size="small" outlined
label={__('Cambia', 'gepafin')}
onClick={() => openRepositoryPicker(dr.code)} />
<Button type="button" icon="pi pi-trash" size="small" outlined severity="danger"
label={__('Rimuovi', 'gepafin')}
onClick={() => RendicontazioneService.deleteEntityFile('document', existing.id,
() => updateDocFile(dr.code, existing.id, null),
(err) => toast.current?.show({ severity: 'error', summary: __('Errore rimozione', 'gepafin'), detail: err?.detail }))
} />
</>
)}
</div>
) : existing && existing.id && existing.filename ? (
// CASO B: file caricato dal PC — usa FileUploadCell standard
<FileUploadCell <FileUploadCell
entityType="document" entityId={existing.id} entityType="document" entityId={existing.id}
filename={existing.filename} sizeBytes={existing.size_bytes} filename={existing.filename} sizeBytes={existing.size_bytes}
@@ -602,9 +752,15 @@ const PraticaRendicontazioneEdit = () => {
toastRef={toast} toastRef={toast}
/> />
) : !readOnly ? ( ) : !readOnly ? (
<Button type="button" icon="pi pi-upload" size="small" outlined // CASO C: doc vuoto — 2 pulsanti
label={__('Carica', 'gepafin')} <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
onClick={() => ensureDocRecord(dr.code, () => {/* reload not needed, setPractice already updated */})} /> <Button type="button" icon="pi pi-upload" size="small" outlined
label={__('Carica dal PC', 'gepafin')}
onClick={() => ensureDocRecord(dr.code, () => {})} />
<Button type="button" icon="pi pi-folder-open" size="small" outlined severity="secondary"
label={__('Scegli dal repository', 'gepafin')}
onClick={() => openRepositoryPicker(dr.code)} />
</div>
) : ( ) : (
<span className="text-color-secondary">{__('Nessun file', 'gepafin')}</span> <span className="text-color-secondary">{__('Nessun file', 'gepafin')}</span>
)} )}
@@ -617,7 +773,102 @@ const PraticaRendicontazioneEdit = () => {
<div className="appPage__spacer"></div> <div className="appPage__spacer"></div>
{/* BOTTOM ACTIONS */} {/* SEZIONE 5: CONTROLLI AGGIUNTIVI (v2) */}
{customChecksDefs.length > 0 && (<>
<div className="appPageSection">
<h2>{__((ulaSection.enabled ? '5.' : '4.') + ' Controlli aggiuntivi (dichiarazioni)', 'gepafin')}</h2>
<p className="text-color-secondary" style={{ marginTop: 0 }}>
{__('Dichiarazioni richieste dal bando oltre ai documenti standard. I controlli obbligatori devono essere tutti dichiarati prima di poter inviare la pratica.', 'gepafin')}
</p>
<div className="fieldsRepeater">
{customChecksDefs.map((def) => {
const val = customChecks.find(c => c.code === def.code) || {};
const declared = !!val.beneficiary_declared;
const hasDoc = !!val.filename_original;
const isMissing = def.required && !declared;
return (
<div key={def.code} className="fieldsRepeater__panel"
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem',
background: isMissing ? 'var(--red-50)' : 'white' }}>
<div style={{ display:'flex', alignItems:'flex-start', gap:'0.75rem' }}>
<div style={{ flex:'0 0 auto', paddingTop:'4px' }}>
<input type="checkbox"
checked={declared}
disabled={readOnly}
onChange={(e) => declareCustomCheck(def.code, e.target.checked, null)}
style={{ width: '20px', height: '20px', cursor: readOnly ? 'default' : 'pointer' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<strong>{__('Dichiaro', 'gepafin')}: {def.label}</strong>
{def.required && (
<Tag severity={isMissing ? 'danger' : 'success'} value={isMissing ? __('Obbligatorio', 'gepafin') : __('OK', 'gepafin')} />
)}
{!def.required && (
<Tag severity="info" value={__('Opzionale', 'gepafin')} />
)}
{val.verification_status && val.verification_status !== 'PENDING' && (
<Tag severity={val.verification_status === 'VALIDO' ? 'success' : 'danger'}
value={val.verification_status} />
)}
</div>
{def.description && (
<div className="text-color-secondary" style={{ fontSize: '0.9em', marginTop: '0.35rem', whiteSpace: 'pre-wrap' }}>
{def.description}
</div>
)}
{def.requires_document && (
<div style={{ marginTop: '0.75rem', padding: '0.6rem', background: 'var(--surface-50)', borderRadius: '4px' }}>
{hasDoc ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<i className="pi pi-file-pdf" style={{ color: 'var(--primary-color)' }} />
<span style={{ flex: 1, minWidth: '150px' }}>
<strong>{val.filename_original}</strong>
{val.size_bytes && <small className="text-color-secondary"> ({(val.size_bytes/1024).toFixed(1)} KB)</small>}
</span>
{!readOnly && (
<Button icon="pi pi-trash" severity="danger" outlined size="small"
label={__('Rimuovi','gepafin')}
onClick={() => deleteCustomCheckDoc(def.code)} />
)}
</div>
) : (
!readOnly && (
<div>
<small className="text-color-secondary">{__('Allega documento (PDF, JPG, PNG — max 15MB):', 'gepafin')}</small>
<input type="file"
accept="application/pdf,image/jpeg,image/png"
disabled={readOnly}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) declareCustomCheck(def.code, declared, f);
e.target.value = '';
}}
style={{ display: 'block', marginTop: '0.4rem' }} />
</div>
)
)}
</div>
)}
{val.verification_notes && (
<div style={{ marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: val.verification_status === 'NON_VALIDO' ? 'var(--red-50)' : 'var(--surface-50)', borderRadius: '4px', fontSize: '0.85em' }}>
<strong>{__('Note istruttore', 'gepafin')}:</strong> {val.verification_notes}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
<div className="appPage__spacer"></div>
</>)}
{/* BOTTOM ACTIONS */}
{!readOnly && ( {!readOnly && (
<div className="appPageSection"> <div className="appPageSection">
<div className="appPageSection__actions"> <div className="appPageSection__actions">
@@ -699,23 +950,52 @@ const PraticaRendicontazioneEdit = () => {
</Dialog> </Dialog>
{/* ---------- DIALOG RISPOSTA SOCCORSO ---------- */} {/* ---------- DIALOG RISPOSTA SOCCORSO ---------- */}
<Dialog visible={amendDialog.visible} style={{ width: '560px' }} <Dialog visible={amendDialog.visible} style={{ width: '720px' }}
header={__('Rispondi al soccorso istruttorio', 'gepafin')} modal header={__('Rispondi al soccorso istruttorio', 'gepafin')} modal
onHide={() => setAmendDialog({ visible: false, amendment: null, responseText: '' })}> onHide={() => setAmendDialog({ visible: false, amendment: null, responseText: '', response_file: null })}>
{amendDialog.amendment && ( {amendDialog.amendment && (
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); submitAmendmentResponse(); }}> <form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); submitAmendmentResponse(); }}>
<div style={{ padding: '0.75rem', background: 'var(--surface-50)', borderRadius: '4px', marginBottom: '1rem' }}> <div style={{ padding: '0.75rem', background: 'var(--surface-50)', borderRadius: '4px', marginBottom: '1rem', border: '1px solid var(--surface-border)' }}>
<small className="text-color-secondary">{__('Richiesta istruttore:', 'gepafin')}</small> <small className="text-color-secondary">
<div style={{ whiteSpace: 'pre-wrap', marginTop: '0.25rem' }}>{amendDialog.amendment.request_text}</div> <strong>{__('Richiesta istruttore', 'gepafin')}</strong>
{amendDialog.amendment.response_days ? `${__('hai', 'gepafin')} ${amendDialog.amendment.response_days} ${__('giorni per rispondere', 'gepafin')}` : ''}
</small>
<div style={{ marginTop: '0.25rem' }}
dangerouslySetInnerHTML={{ __html: amendDialog.amendment.request_text || '' }} />
{amendDialog.amendment.amendment_document_path && (
<div style={{ marginTop: '0.5rem', fontSize: '0.9em' }}>
<i className="pi pi-paperclip" style={{ marginRight: '0.25rem' }} />
<span className="text-color-secondary">{__('L\'istruttore ha allegato un documento (trasmesso via PEC)', 'gepafin')}</span>
</div>
)}
<div style={{ marginTop: '0.5rem', fontSize: '0.9em', color: 'var(--text-color-secondary)' }}>
<i className="pi pi-calendar" style={{ marginRight: '0.25rem' }} />
{__('Scadenza:', 'gepafin')} <strong>{new Date(amendDialog.amendment.deadline).toLocaleDateString('it-IT')}</strong>
</div>
</div> </div>
<div className="appForm__field"> <div className="appForm__field">
<label>{__('La tua risposta', 'gepafin')}</label> <label>{__('La tua risposta', 'gepafin')} <span style={{color:'var(--red-500)'}}>*</span></label>
<InputTextarea value={amendDialog.responseText} rows={5} autoResize <Editor value={amendDialog.responseText} style={{ height: '180px' }}
onChange={(e) => setAmendDialog(d => ({ ...d, responseText: e.target.value }))} onTextChange={(e) => setAmendDialog(d => ({ ...d, responseText: e.htmlValue || '' }))}
placeholder={__('Descrivi le integrazioni fornite, allegati caricati, chiarimenti...', 'gepafin')} /> placeholder={__('Descrivi le integrazioni fornite, gli allegati caricati, i chiarimenti...', 'gepafin')} />
</div> </div>
<div className="appForm__field">
<label>{__('Allegato alla risposta (opzionale, PDF)', 'gepafin')}</label>
<FileUpload ref={responseFileRef} mode="basic" auto={false}
chooseLabel={__('Scegli file PDF', 'gepafin')}
accept="application/pdf" maxFileSize={10*1024*1024}
customUpload uploadHandler={() => {}}
onSelect={(e) => setAmendDialog(d => ({ ...d, response_file: e.files[0] || null }))} />
<small className="text-color-secondary">
{__('Documento integrativo (DURC aggiornato, chiarimento contabile, ecc.)', 'gepafin')}
</small>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setAmendDialog({ visible: false, amendment: null, responseText: '' })} /> <Button type="button" outlined label={__('Annulla', 'gepafin')}
onClick={() => setAmendDialog({ visible: false, amendment: null, responseText: '', response_file: null })} />
<Button type="submit" label={__('Invia risposta', 'gepafin')} icon="pi pi-send" severity="warning" /> <Button type="submit" label={__('Invia risposta', 'gepafin')} icon="pi pi-send" severity="warning" />
</div> </div>
</form> </form>
@@ -788,6 +1068,14 @@ const PraticaRendicontazioneEdit = () => {
</form> </form>
)} )}
</Dialog> </Dialog>
<CompanyDocumentPicker
visible={repoPicker.visible}
companyId={practice?.company_id}
currentSourceId={(practice?.documents?.find(d => d.doc_code === repoPicker.docCode) || {}).source_company_document_id || null}
onHide={closeRepositoryPicker}
onSelect={handleRepositoryPick}
/>
<FilePreviewDialog <FilePreviewDialog
visible={previewDialog.visible} visible={previewDialog.visible}
onHide={closePreview} onHide={closePreview}

View File

@@ -3,37 +3,145 @@ import { __ } from '@wordpress/i18n';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button'; import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Tag } from 'primereact/tag'; import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast'; import { Toast } from 'primereact/toast';
import { Skeleton } from 'primereact/skeleton'; import { Skeleton } from 'primereact/skeleton';
import { Dialog } from 'primereact/dialog';
import { InputText } from 'primereact/inputtext';
import { Checkbox } from 'primereact/checkbox';
import { Card } from 'primereact/card';
import { Divider } from 'primereact/divider';
import RendicontazioneService from '../service/rendicontazioneService'; import RendicontazioneService from '../service/rendicontazioneService';
const STATUS_TAGS = { const STATUS_TAGS = {
NOT_STARTED: { severity: 'info', label: 'Da avviare' }, DRAFT: { severity: 'warning', label: 'In compilazione', icon: 'pi pi-pencil' },
DRAFT: { severity: 'warning', label: 'In compilazione' }, SUBMITTED: { severity: 'info', label: 'Inviata', icon: 'pi pi-send' },
SUBMITTED: { severity: 'info', label: 'Inviata' }, UNDER_REVIEW: { severity: 'info', label: 'In valutazione', icon: 'pi pi-eye' },
UNDER_REVIEW: { severity: 'info', label: 'In valutazione' }, APPROVED: { severity: 'success', label: 'Approvata', icon: 'pi pi-check-circle' },
APPROVED: { severity: 'success', label: 'Approvata' }, REJECTED: { severity: 'danger', label: 'Respinta', icon: 'pi pi-times-circle' },
REJECTED: { severity: 'danger', label: 'Respinta' }, AWAITING_AMENDMENT: { severity: 'warning', label: 'Soccorso istruttorio',icon: 'pi pi-exclamation-triangle' }
AWAITING_AMENDMENT: { severity: 'warning', label: 'Soccorso istruttorio' }
}; };
const fmtEur = (v) => {
const n = Number(v || 0);
return `${n.toLocaleString('it-IT', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
// -------- Sottocomponenti di presentazione --------
const StatTile = ({ label, value, accent = 'var(--text-color)', muted = false }) => (
<div style={{
flex: '1 1 180px',
minWidth: '160px',
padding: '0.75rem 1rem',
background: 'white',
borderLeft: `3px solid ${accent}`,
borderRadius: '4px',
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
}}>
<div style={{
fontSize: '0.75rem',
letterSpacing: '0.02em',
color: 'var(--text-color-secondary)',
textTransform: 'uppercase',
marginBottom: '0.25rem',
}}>
{label}
</div>
<div style={{
fontSize: '1.15rem',
fontWeight: 700,
color: muted ? 'var(--text-color-secondary)' : accent,
}}>
{value}
</div>
</div>
);
const TrancheRow = ({ tranche, onOpen, isLast }) => {
const tag = STATUS_TAGS[tranche.status] || { severity: 'secondary', label: tranche.status, icon: 'pi pi-circle' };
const isEditable = tranche.status === 'DRAFT' || tranche.status === 'AWAITING_AMENDMENT';
const hasContent = (tranche.invoice_count || tranche.ula_count || tranche.document_count);
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
padding: '0.85rem 0',
borderBottom: isLast ? 'none' : '1px solid var(--surface-border)',
}}>
{/* Icona circolare con numero tranche */}
<div style={{
flexShrink: 0,
width: '2.5rem',
height: '2.5rem',
borderRadius: '50%',
background: 'var(--primary-color)',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: '0.9rem',
}}>
T{tranche.sequence_number}
</div>
{/* Descrizione tranche */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>
{tranche.period_label || <span style={{ color: 'var(--text-color-secondary)', fontWeight: 400, fontStyle: 'italic' }}>{__('Nessun periodo indicato','gepafin')}</span>}
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-color-secondary)' }}>
{hasContent ? (
<>
<i className="pi pi-file" style={{ marginRight: '4px', fontSize: '0.8em' }} />
{tranche.invoice_count || 0} {__('fatture','gepafin')}
<span style={{ margin: '0 6px' }}>·</span>
<i className="pi pi-users" style={{ marginRight: '4px', fontSize: '0.8em' }} />
{tranche.ula_count || 0} {__('dipendenti','gepafin')}
<span style={{ margin: '0 6px' }}>·</span>
<i className="pi pi-paperclip" style={{ marginRight: '4px', fontSize: '0.8em' }} />
{tranche.document_count || 0} {__('documenti','gepafin')}
</>
) : (
<em>{__('Nessun contenuto ancora inserito','gepafin')}</em>
)}
</div>
</div>
{/* Stato + CTA */}
<Tag icon={tag.icon} value={tag.label} severity={tag.severity} />
<Button
icon={isEditable ? 'pi pi-pencil' : 'pi pi-eye'}
size="small"
outlined={!isEditable}
severity={isEditable ? null : 'secondary'}
label={isEditable ? __('Continua','gepafin') : __('Apri','gepafin')}
onClick={() => onOpen(tranche.id)}
/>
</div>
);
};
// -------- Componente principale --------
const RendicontazioniMie = () => { const RendicontazioniMie = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const toast = useRef(null); const toast = useRef(null);
const [rows, setRows] = useState([]); const [apps, setApps] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [startDialog, setStartDialog] = useState(null);
const [startForm, setStartForm] = useState({ period_label: '', copy_ula: true });
const [starting, setStarting] = useState(false);
const load = () => { const load = () => {
setLoading(true); setLoading(true);
RendicontazioneService.listMine( RendicontazioneService.listMine(
(resp) => { (resp) => {
const practices = (resp?.data?.practices || []).map(p => ({ ...p, isReady: false })); setApps(resp?.data?.applications || []);
const ready = (resp?.data?.ready_to_start || []).map(r => ({ ...r, isReady: true }));
setRows([...practices, ...ready]);
setLoading(false); setLoading(false);
}, },
(err) => { (err) => {
@@ -45,53 +153,220 @@ const RendicontazioniMie = () => {
useEffect(() => { load(); /* eslint-disable-next-line */ }, []); useEffect(() => { load(); /* eslint-disable-next-line */ }, []);
const handleStart = (applicationId) => { const openStartDialog = (app) => {
RendicontazioneService.startPractice(applicationId, const nextSeq = (app.tranches?.length || 0) + 1;
setStartDialog({
application_id: app.application_id,
call_name: app.call_name,
max_tranches: app.max_tranches,
next_seq: nextSeq,
show_copy_ula: nextSeq > 1,
max_remission_next: app.max_remission_next_tranche,
});
setStartForm({ period_label: '', copy_ula: nextSeq > 1 });
};
const confirmStart = () => {
if (!startDialog) return;
setStarting(true);
RendicontazioneService.startPractice(
startDialog.application_id,
(resp) => { (resp) => {
toast.current?.show({ severity: 'success', summary: __('Rendicontazione avviata', 'gepafin') }); setStarting(false);
setStartDialog(null);
toast.current?.show({ severity: 'success', summary: resp?.message || __('Tranche avviata', 'gepafin') });
navigate(`/rendicontazioni/${resp.data.id}`); navigate(`/rendicontazioni/${resp.data.id}`);
}, },
(err) => toast.current?.show({ severity: 'error', summary: __('Avvio fallito', 'gepafin'), detail: err?.detail }) (err) => {
setStarting(false);
toast.current?.show({ severity: 'error', summary: __('Avvio fallito', 'gepafin'), detail: err?.detail });
},
{
period_label: startForm.period_label?.trim() || null,
copy_ula_from_previous: startForm.copy_ula,
}
); );
}; };
const callTpl = (row) => ( const renderApplicationCard = (app) => {
<div> const tranchesCount = app.tranches?.length || 0;
<strong>{row.call_name || `Bando #${row.call_id}`}</strong> const hasTranches = tranchesCount > 0;
<div><small className="text-color-secondary">{row.company_name}</small></div> const nextSeq = tranchesCount + 1;
</div> const canStart = !!app.can_start_new;
); const blockReason = app.start_blocked_reason;
const progressPct = app.max_remission_global > 0
? Math.min(100, (app.already_approved_sum / app.max_remission_global) * 100)
: 0;
const erogatoTpl = (row) => { const headerTemplate = (
const v = Number(row.amount_erogato || 0); <div style={{
return <strong> {v.toLocaleString('it-IT', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong>; display: 'flex',
}; justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '1rem',
padding: '1.25rem 1.5rem 0.75rem',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<h3 style={{ margin: 0, color: 'var(--primary-color)', fontSize: '1.2rem' }}>
{app.call_name || `Bando #${app.call_id}`}
</h3>
<div style={{ color: 'var(--text-color-secondary)', fontSize: '0.85rem', marginTop: '4px' }}>
<i className="pi pi-building" style={{ marginRight: '4px' }} />
{app.company_name || '—'}
<span style={{ margin: '0 6px' }}>·</span>
{__('Domanda', 'gepafin')} <strong>#{app.application_id}</strong>
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-color-secondary)', textTransform: 'uppercase', letterSpacing: '0.02em' }}>
{__('Finanziamento erogato','gepafin')}
</div>
<div style={{ fontSize: '1.4rem', fontWeight: 700, lineHeight: 1.1 }}>
{fmtEur(app.amount_erogato)}
</div>
</div>
</div>
);
const statusTpl = (row) => {
const key = row.isReady ? 'NOT_STARTED' : (row.status || 'DRAFT');
const conf = STATUS_TAGS[key] || { severity: 'secondary', label: key };
return <Tag value={conf.label} severity={conf.severity} />;
};
const progressTpl = (row) => {
if (row.isReady) return <span className="text-color-secondary"></span>;
return ( return (
<span className="text-color-secondary" style={{ fontSize: '0.9em' }}> <Card key={app.application_id}
{row.invoice_count || 0} {__('fatture','gepafin')} · {row.ula_count || 0} {__('dipendenti','gepafin')} · {row.document_count || 0} {__('doc','gepafin')} header={headerTemplate}
</span> style={{ marginBottom: '1.5rem', borderRadius: '8px', overflow: 'hidden' }}>
);
};
const actionsTpl = (row) => { {/* BLOCCO TOTALI — 4 stat tile affiancati */}
if (row.isReady) { <div style={{
return <Button icon="pi pi-play" label={__('Avvia rendicontazione', 'gepafin')} display: 'flex',
size="small" severity="success" onClick={() => handleStart(row.application_id)} />; flexWrap: 'wrap',
} gap: '0.75rem',
const isEditable = row.status === 'DRAFT'; padding: '0.25rem 0.25rem 1rem',
return <Button icon={isEditable ? 'pi pi-pencil' : 'pi pi-eye'} }}>
label={isEditable ? __('Continua', 'gepafin') : __('Apri', 'gepafin')} <StatTile
size="small" outlined={!isEditable} label={__('Cap remissione totale', 'gepafin')}
onClick={() => navigate(`/rendicontazioni/${row.id}`)} />; value={fmtEur(app.max_remission_global)}
accent="#475569"
/>
<StatTile
label={__('Già approvato', 'gepafin')}
value={fmtEur(app.already_approved_sum)}
accent="#16a34a"
muted={!app.already_approved_sum}
/>
<StatTile
label={__('Disponibile prossima tranche', 'gepafin')}
value={fmtEur(app.max_remission_next_tranche)}
accent="var(--primary-color)"
/>
<StatTile
label={__('Tranches', 'gepafin')}
value={`${tranchesCount} / ${app.max_tranches}`}
accent="#64748b"
/>
</div>
{/* PROGRESS BAR utilizzo cap */}
{app.max_remission_global > 0 && (
<div style={{ padding: '0 0.25rem 0.75rem' }}>
<div style={{
height: '6px',
background: 'var(--surface-200)',
borderRadius: '3px',
overflow: 'hidden',
position: 'relative',
}}>
<div style={{
height: '100%',
width: `${progressPct}%`,
background: progressPct >= 100 ? 'var(--red-500)' : '#16a34a',
transition: 'width 0.3s',
}} />
</div>
<div style={{
fontSize: '0.75rem',
color: 'var(--text-color-secondary)',
marginTop: '4px',
textAlign: 'right',
}}>
{progressPct.toFixed(1)}% {__('del cap già utilizzato','gepafin')}
</div>
</div>
)}
{/* TRANCHES */}
{hasTranches ? (
<div style={{ padding: '0.5rem 0.25rem 0' }}>
<div style={{
fontSize: '0.75rem',
color: 'var(--text-color-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.02em',
marginBottom: '0.25rem',
fontWeight: 600,
}}>
{__('Elenco tranche','gepafin')}
</div>
<div style={{ borderTop: '1px solid var(--surface-border)' }}>
{app.tranches.map((t, idx) => (
<TrancheRow
key={t.id}
tranche={t}
onOpen={(id) => navigate(`/rendicontazioni/${id}`)}
isLast={idx === app.tranches.length - 1}
/>
))}
</div>
</div>
) : (
<div style={{
padding: '1.5rem',
textAlign: 'center',
color: 'var(--text-color-secondary)',
background: 'var(--surface-50)',
borderRadius: '6px',
fontSize: '0.9rem',
}}>
<i className="pi pi-info-circle" style={{ marginRight: '6px' }} />
{__('Non hai ancora avviato nessuna tranche di rendicontazione per questo bando.','gepafin')}
</div>
)}
{/* FOOTER — bottone nuova tranche */}
<Divider style={{ margin: '1rem 0 0.75rem' }} />
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.75rem',
flexWrap: 'wrap',
}}>
{!canStart && blockReason ? (
<div style={{
fontSize: '0.85rem',
color: 'var(--text-color-secondary)',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}>
<i className="pi pi-info-circle" />
<em>{blockReason}</em>
</div>
) : <span />}
<Button
icon="pi pi-plus-circle"
iconPos="left"
label={hasTranches
? `${__('Nuova tranche','gepafin')} (T${nextSeq})`
: __('Avvia rendicontazione','gepafin')}
severity={canStart ? 'success' : null}
disabled={!canStart}
outlined={!canStart}
tooltip={!canStart ? blockReason : undefined}
tooltipOptions={{ position: 'top' }}
onClick={() => openStartDialog(app)}
/>
</div>
</Card>
);
}; };
return ( return (
@@ -100,32 +375,105 @@ const RendicontazioniMie = () => {
<div className="appPage__pageHeader"> <div className="appPage__pageHeader">
<h1>{__('Le mie rendicontazioni', 'gepafin')}</h1> <h1>{__('Le mie rendicontazioni', 'gepafin')}</h1>
<p>{__('Per ogni pratica finanziata puoi avviare la rendicontazione delle spese e il calcolo della remissione del debito.', 'gepafin')}</p> <p>{__('Per ogni pratica finanziata puoi avviare la rendicontazione delle spese e il calcolo della remissione del debito. I bandi che prevedono piu tranches permettono rendicontazioni multi-fase.', 'gepafin')}</p>
</div> </div>
<div className="appPage__spacer"></div> <div className="appPage__spacer"></div>
<div className="appPageSection"> {loading && (
{loading && <Skeleton width="100%" height="10rem" />} <div>
{!loading && rows.length === 0 && ( <Skeleton width="100%" height="14rem" style={{ marginBottom: '1rem' }} />
<div style={{ padding: '2rem', textAlign: 'center', width: '100%' }}> <Skeleton width="100%" height="14rem" />
<i className="pi pi-inbox" style={{ fontSize: '2.5rem', color: 'var(--text-color-secondary)', display: 'block', marginBottom: '0.75rem' }} /> </div>
<p>{__('Non ci sono rendicontazioni da avviare al momento.', 'gepafin')}</p> )}
<small className="text-color-secondary">
{!loading && apps.length === 0 && (
<Card>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '2.5rem 1.5rem',
textAlign: 'center',
}}>
<i className="pi pi-inbox" style={{ fontSize: '2.5rem', color: 'var(--text-color-secondary)', marginBottom: '0.75rem' }} />
<p style={{ fontSize: '1rem', margin: 0 }}>{__('Non ci sono rendicontazioni disponibili al momento.', 'gepafin')}</p>
<small className="text-color-secondary" style={{ marginTop: '0.5rem', maxWidth: '28rem' }}>
{__('Le rendicontazioni diventano disponibili dopo la firma del contratto e quando l\'ente ha pubblicato lo schema di rendicontazione per il bando.', 'gepafin')} {__('Le rendicontazioni diventano disponibili dopo la firma del contratto e quando l\'ente ha pubblicato lo schema di rendicontazione per il bando.', 'gepafin')}
</small> </small>
</div> </div>
</Card>
)}
{!loading && apps.length > 0 && apps.map(renderApplicationCard)}
{/* START DIALOG */}
<Dialog
header={__('Avvia nuova tranche di rendicontazione', 'gepafin')}
visible={!!startDialog}
style={{ width: '32rem' }}
onHide={() => !starting && setStartDialog(null)}
modal
footer={(
<div>
<Button label={__('Annulla', 'gepafin')} icon="pi pi-times"
onClick={() => setStartDialog(null)} outlined disabled={starting} />
<Button label={__('Avvia tranche', 'gepafin')} icon="pi pi-play" iconPos="right"
severity="success" loading={starting} onClick={confirmStart} />
</div>
)} )}
{!loading && rows.length > 0 && ( >
<DataTable value={rows} dataKey="id" stripedRows responsiveLayout="scroll" style={{ width: '100%' }}> {startDialog && (
<Column header={__('Bando', 'gepafin')} body={callTpl} /> <div>
<Column header={__('Importo erogato', 'gepafin')} body={erogatoTpl} style={{ width: '180px' }} /> <div style={{
<Column header={__('Stato', 'gepafin')} body={statusTpl} style={{ width: '180px' }} /> padding: '0.75rem 1rem',
<Column header={__('Avanzamento', 'gepafin')} body={progressTpl} /> background: 'var(--surface-50)',
<Column header={__('Azione', 'gepafin')} body={actionsTpl} style={{ width: '220px' }} /> borderRadius: '6px',
</DataTable> marginBottom: '1rem',
}}>
<div style={{ fontSize: '0.9rem' }}>
{__('Stai per avviare la tranche', 'gepafin')}
{' '}<strong>T{startDialog.next_seq}</strong> / {startDialog.max_tranches}
{' '}{__('del bando', 'gepafin')}
</div>
<div style={{ fontSize: '1rem', fontWeight: 700, marginTop: '4px', color: 'var(--primary-color)' }}>
{startDialog.call_name}
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-color-secondary)', marginTop: '6px' }}>
{__('Cap remissione disponibile', 'gepafin')}:
{' '}<strong style={{ color: '#16a34a' }}>{fmtEur(startDialog.max_remission_next)}</strong>
</div>
</div>
<div className="appForm__field">
<label>{__('Periodo / fase (opzionale)', 'gepafin')}</label>
<InputText value={startForm.period_label}
onChange={(e) => setStartForm(f => ({ ...f, period_label: e.target.value }))}
placeholder={__('es. "I trimestre 2021", "Stato avanzamento II"', 'gepafin')}
disabled={starting} />
<small className="text-color-secondary">
{__('Descrizione libera per identificare la tranche. Apparirà sul verbale.', 'gepafin')}
</small>
</div>
{startDialog.show_copy_ula && (
<div className="appForm__field" style={{ marginTop: '1rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<Checkbox inputId="copy_ula" checked={startForm.copy_ula}
onChange={(e) => setStartForm(f => ({ ...f, copy_ula: e.checked }))}
disabled={starting} />
<label htmlFor="copy_ula" style={{ cursor: 'pointer', margin: 0 }}>
{__('Copia i dipendenti ULA dalla tranche precedente', 'gepafin')}
</label>
</div>
<small className="text-color-secondary">
{__('Se attivo, i dipendenti censiti nella tranche precedente saranno precaricati. Potrai modificarli o rimuoverli prima di inviare.', 'gepafin')}
</small>
</div>
)}
</div>
)} )}
</div> </Dialog>
</div> </div>
); );
}; };

View File

@@ -88,6 +88,39 @@ const RendicontazioneService = {
export default RendicontazioneService; export default RendicontazioneService;
// =========================================================================
// v2.1 — Picker schema (blank / template / clone)
// =========================================================================
export const schemaPickerService = {
listTemplates(onSuccess, onError) {
fetch(`${BASE_URL}/api/rendicontazione-schemas/templates`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
listClonableCalls(onSuccess, onError) {
fetch(`${BASE_URL}/api/rendicontazione-schemas/clonable-calls`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
initializeSchema(callId, payload, onSuccess, onError) {
// payload = { source: "blank"|"template"|"clone", template_id?, source_call_id? }
fetch(`${BASE_URL}/api/rendicontazione-schemas/${callId}/initialize`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
}
};
// ====================== PRATICHE BENEFICIARIO ====================== // ====================== PRATICHE BENEFICIARIO ======================
const extendPractice = { const extendPractice = {
@@ -97,10 +130,21 @@ const extendPractice = {
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError)); }).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
}, },
startPractice(applicationId, onSuccess, onError) { startPractice(applicationId, onSuccess, onError, opts = {}) {
// opts: { period_label?: string, copy_ula_from_previous?: bool }
fetch(`${BASE_URL}/api/remission-practices/start`, { fetch(`${BASE_URL}/api/remission-practices/start`, {
method: 'POST', mode: 'cors', headers: buildHeaders(), method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ application_id: applicationId }) body: JSON.stringify({
application_id: applicationId,
period_label: opts.period_label ?? null,
copy_ula_from_previous: opts.copy_ula_from_previous !== false,
})
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
copyUlaOptions(practiceId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/copy-ula-options`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError)); }).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
}, },
@@ -234,6 +278,66 @@ const extendInstructor = {
method: 'POST', mode: 'cors', headers: buildHeaders(), method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ response_text: responseText }) body: JSON.stringify({ response_text: responseText })
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError)); }).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
// ====== AMENDMENT v3: DRAFT lifecycle + extend + reminder + uploads ======
updateAmendment(practiceId, amendmentId, body, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(body)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
deleteAmendment(practiceId, amendmentId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}`, {
method: 'DELETE', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
sendAmendment(practiceId, amendmentId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/send`, {
method: 'POST', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
extendAmendment(practiceId, amendmentId, extendedDays, motivation, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/extend`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ extended_days: extendedDays, motivation })
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
sendAmendmentReminder(practiceId, amendmentId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/reminder`, {
method: 'POST', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
uploadAmendmentDocument(practiceId, amendmentId, file, onSuccess, onError) {
const fd = new FormData();
fd.append('file', file);
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/upload-document`, {
method: 'POST', mode: 'cors',
headers: _buildBearerOnly(),
body: fd
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
deleteAmendmentDocument(practiceId, amendmentId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/upload-document`, {
method: 'DELETE', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
uploadResponseDocument(practiceId, amendmentId, file, onSuccess, onError) {
const fd = new FormData();
fd.append('file', file);
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/upload-response-document`, {
method: 'POST', mode: 'cors',
headers: _buildBearerOnly(),
body: fd
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
} }
}; };
@@ -298,6 +402,20 @@ const extendFiles = {
.catch(e => handleError(e, onError)); .catch(e => handleError(e, onError));
}, },
/**
* Collega un remission_document a un company_document del repository Gepafin.
* Alternativa all upload dal PC: copia filename/expires_at/storage_path dal sorgente
* e traccia source_company_document_id per lookup live dello status (VALID/DUE/EXPIRED).
* Usato solo su entityType='document'.
*/
linkDocumentFromRepository(remissionDocumentId, companyDocumentId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-files/document/${remissionDocumentId}/link-from-repository`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ company_document_id: companyDocumentId })
}).then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
/** /**
* Elimina file allegato a una entita. * Elimina file allegato a una entita.
*/ */
@@ -400,3 +518,84 @@ const extendVerbale = {
}; };
Object.assign(RendicontazioneService, extendVerbale); Object.assign(RendicontazioneService, extendVerbale);
// ====================== v2 CUSTOM CHECKS ======================
const extendCustomChecks = {
listCustomChecks(practiceId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
declareCustomCheck(practiceId, code, declared, file, onSuccess, onError) {
const fd = new FormData();
fd.append('beneficiary_declared', declared ? 'true' : 'false');
if (file) fd.append('file', file);
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks/${code}/declare`, {
method: 'PUT', mode: 'cors',
headers: _buildBearerOnly(), // no Content-Type: boundary auto
body: fd
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
deleteCustomCheckDocument(practiceId, code, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks/${code}/document`, {
method: 'DELETE', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
verifyCustomCheck(practiceId, code, body, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks/${code}/verify`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(body)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
fetchCustomCheckDocumentBlob(practiceId, code, inline, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks/${code}/document?inline=${inline ? 1 : 0}`, {
method: 'GET', mode: 'cors', headers: _buildBearerOnly()
}).then(async r => {
if (r.status < 200 || r.status >= 300) {
let detail = r.statusText;
try { const j = await r.json(); detail = j.detail || detail; } catch(e){}
if (onError) onError({ status: r.status, detail });
return;
}
let filename = 'file';
const cd = r.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
if (m) filename = m[1];
const blob = await r.blob();
const objectUrl = URL.createObjectURL(blob);
if (onSuccess) onSuccess({ blob, objectUrl, filename });
}).catch(e => handleError(e, onError));
}
};
Object.assign(RendicontazioneService, extendCustomChecks);
// ====================== v2 MANAGER ISTRUTTORE ======================
const extendAssignmentManager = {
managerAssignments(onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor-manager/assignments`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
managerInstructorsList(onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor-manager/instructors`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
reassignInstructor(practiceId, newInstructorId, reason, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/reassign`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({
new_instructor_id: newInstructorId,
reassignment_reason: reason || null,
})
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
}
};
Object.assign(RendicontazioneService, extendAssignmentManager);

View File

@@ -21,6 +21,10 @@ import PraticaRendicontazioneEdit from './modules/rendicontazione/pages/PraticaR
import DevSwitchUser from './modules/rendicontazione/pages/DevSwitchUser'; import DevSwitchUser from './modules/rendicontazione/pages/DevSwitchUser';
import IstruttoriaQueue from './modules/rendicontazione/pages/IstruttoriaQueue'; import IstruttoriaQueue from './modules/rendicontazione/pages/IstruttoriaQueue';
import IstruttoriaPratica from './modules/rendicontazione/pages/IstruttoriaPratica'; import IstruttoriaPratica from './modules/rendicontazione/pages/IstruttoriaPratica';
import Ar1Home from './modules/ar1/pages/Ar1Home';
import Ar1Wizard from './modules/ar1/pages/Ar1Wizard';
import Ar1Signature from './modules/ar1/pages/Ar1Signature';
import Ar1AdminConfig from './modules/ar1/pages/Ar1AdminConfig';
import BandoFlowEdit from './pages/BandoFlowEdit'; import BandoFlowEdit from './pages/BandoFlowEdit';
import Imieibandi from './pages/Imieibandi'; import Imieibandi from './pages/Imieibandi';
import BandoApplication from './pages/BandoApplication'; import BandoApplication from './pages/BandoApplication';
@@ -163,6 +167,34 @@ const routes = ({ role, chosenCompanyId }) => {
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null} {'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null} {'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
</DefaultLayout>}/> </DefaultLayout>}/>
<Route path="/ar1" element={<DefaultLayout>
{'ROLE_BENEFICIARY' === role ? <Ar1Home/> : null}
{'ROLE_SUPER_ADMIN' === role ? <Ar1Home/> : null}
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
</DefaultLayout>}/>
<Route path="/ar1/wizard/:formId" element={<DefaultLayout>
{'ROLE_BENEFICIARY' === role ? <Ar1Wizard/> : null}
{'ROLE_SUPER_ADMIN' === role ? <Ar1Wizard/> : null}
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
</DefaultLayout>}/>
<Route path="/ar1/signature/:formId" element={<DefaultLayout>
{'ROLE_BENEFICIARY' === role ? <Ar1Signature/> : null}
{'ROLE_SUPER_ADMIN' === role ? <Ar1Signature/> : null}
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
</DefaultLayout>}/>
<Route path="/ar1-admin" element={<DefaultLayout>
{'ROLE_SUPER_ADMIN' === role ? <Ar1AdminConfig/> : <PageNotFound/>}
{'ROLE_BENEFICIARY' === role ? <PageNotFound/> : null}
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
</DefaultLayout>}/>
<Route path="/rendicontazioni/:id" element={<DefaultLayout> <Route path="/rendicontazioni/:id" element={<DefaultLayout>
{'ROLE_BENEFICIARY' === role ? <PraticaRendicontazioneEdit/> : null} {'ROLE_BENEFICIARY' === role ? <PraticaRendicontazioneEdit/> : null}
{'ROLE_SUPER_ADMIN' === role ? <PraticaRendicontazioneEdit/> : null} {'ROLE_SUPER_ADMIN' === role ? <PraticaRendicontazioneEdit/> : null}