21 Commits

Author SHA1 Message Date
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
BFLOWS
fca18de751 feat(rendicontazione): integrazione upload reale + preview PDF + verbale nei flussi
IstruttoriaPratica.js:
- previewDialog esteso con entityType/entityId (non piu solo filename)
- openPreview/closePreview/doDownload rimpiazzano openPreview/downloadStub stub
- Dialog placeholder 'anteprima simulata' rimosso, sostituito con <FilePreviewDialog/>
- Bottoni anteprima/scarica in fatture/ULA/documenti usano gli endpoint reali
  (disabled se !storage_path)
- Nuovi bottoni 'Anteprima verbale' (HTML tab) e 'Scarica verbale PDF'
  nella toolbar per status in UNDER_REVIEW/AWAITING_AMENDMENT/APPROVED/REJECTED
- downloadVerbale/openVerbaleHtml helpers

PraticaRendicontazioneEdit.js:
- previewDialog state + openPreview/closePreview
- updateInvoiceFile/updateUlaFile/updateDocFile: aggiornano lo stato locale
  dopo upload/delete senza full reload pagina
- ensureDocRecord: auto-crea RemissionDocument (via upsertDocument con filename=null)
  prima dell'upload cosi FileUploadCell ha un entityId valido
- Colonne 'Allegato' nelle DataTable fatture/ULA ora renderizzano <FileUploadCell/>
  con onPreview/onChange wired
- Sezione documenti: FileUploadCell per record esistenti, bottone 'Carica'
  per record non ancora creati
- Modal fattura: rimosso campo 'Nome file PDF (simulato)', infobox post-save guida
  al caricamento dalla tabella
- Modal dipendente: rimosso campo 'Nome file allegato (simulato)', infobox analogo
- <FilePreviewDialog/> montato in chiusura

Test JSX: @babel/parser OK su entrambi i file. Webpack ricompila hot-reload.
2026-04-18 16:55:22 +02:00
BFLOWS
fe0b4f1113 feat(rendicontazione): componenti riusabili upload/preview PDF
- components/FilePreviewDialog: Dialog full-height con iframe,
  blob URL autenticato (fetch + Bearer token), revoca URL alla chiusura,
  bottone scarica, usato da istruttore e beneficiario
- components/FileUploadCell: cella compatta per righe DataTable,
  stati nessun-file/uploading/caricato, upload drag&drop (accept .pdf/.jpg/.png max 15MB),
  preview/download/sostituisci/elimina con conferma confirmPopup, readOnly per istruttore
- service: 4 nuovi metodi file (uploadEntityFile multipart senza Content-Type forzato,
  deleteEntityFile, fetchEntityFileBlob con parse Content-Disposition,
  downloadEntityFile con anchor tag e revoke URL)
- service: 2 metodi verbale (downloadVerbale blob PDF,
  openVerbaleHtml apre HTML in nuova tab per preview rapida)

Nessun pdf.js, solo iframe nativo + ObjectURL. Zero dipendenze aggiuntive.
2026-04-18 16:55:06 +02:00
BFLOWS Sandbox
2268fd98f5 feat(istruttoria UI): tabelle inline editabili, toggle check/reject, no-reload, allineamento ULA
Refactor completo della UI istruttore su pattern Excel-like dichiarato/verificato.

Editor schema bando (BandoRendicontazioneSchemaEdit):
- Nuovo dropdown 'Base di calcolo ammissibile' (imponibile/totale/regime-dependent)
- Nuovo Calendar 'Inizio periodo' accanto al period_start_rule esistente

IstruttoriaPratica — refactor totale:
- FATTURE: 1 sola DataTable con rowGroupMode='subheader' raggruppato per B1/B2/B3,
  header colorato per categoria con totali dichiarato/ammesso live
- Colonne inline editabili: 'Imponibile ammesso' con InputNumber + save onBlur.
  Stato auto-calcolato: = dichiarato -> AMMESSA; 0 < x < dichiarato -> PARZIALE;
  x == 0 -> RESPINTA
- Label dinamiche 'Imponibile' vs 'Totale' in base a use_taxable_only
- Riga espandibile (pi-chevron) con textarea note istruttore + dettaglio IVA/totale
- Toggle icon ✓: se AMMESSA -> PENDING; altrimenti -> AMMESSA
- Toggle icon ✗: se RESPINTA -> PENDING; altrimenti -> RESPINTA
- Tooltip dinamici 'Conferma' / 'Annulla conferma'
- Badge rosso automatico 'Data fuori periodo' su invoice_in_period=false

ULA: stesso pattern inline (FTE dichiarato vs FTE ammesso) con header-box
manuale SOPRA la DataTable (non rowGroupMode, un solo gruppo) e forzatura
tableStyle width:100% per allineamento perfetto con fatture.

Documenti: lista con toggle ✓ VALIDO ↔ PENDING, ✗ NON_VALIDO/SCADUTO via dialog.

Performance critica — NO FULL RELOAD su verify:
- saveInvoiceInline/saveUlaInline/quickVerifyDoc/saveDocNote ora fanno
  setBundle() con update locale della singola riga
- refreshGateOnly() ricarica solo il gate_check (totali) in background
- Eliminato il load() completo che faceva sfarfallare la pagina

Banner arancione automatico quando status=SUBMITTED: 'Pratica non presa in
carico' con CTA 'Prendi in carico'.

Bugfix:
- Rimossi import inutilizzati (InputText, isNil)
- Aggiunti import DataTable, Column

UX testata su NAPOLI SAS: 5 fatture 3 categorie, 2 ULA, 4 docs.
Totali si aggiornano live, toggle funzionanti, nessuno sfarfallio.
2026-04-18 12:27:10 +02:00
BFLOWS Sandbox
61cdfbd06b feat(istruttoria UI): verifica riga-per-riga con thumbs up/down/occhio/download/rettifica
Replica il workflow del foglio Excel originale dell'istruttoria Gepafin.
Pattern preso da DomandaEditPreInstructor/components/ListOfFiles.

Pagina IstruttoriaPratica riscritta (858 righe):

RIEPILOGO FINANZIARIO esteso:
- Totale dichiarato (dal beneficiario)
- Totale verificato (somma AMMESSA + PARZIALE istruttore)
- Cap remissione (min(50% erogato, 12500))
- Remissione da riconoscere (da verificato)
- Residuo da restituire (erogato - remissione)

VERIFICA FATTURE per categoria con appPageSection__list:
- Ogni fattura come row con numero, fornitore, date, descrizione, Tag stato
- Tag rosso 'Date fuori periodo' se invoice_in_period=false O payment_in_period=false
- Riga dichiarato + riga verificato (se presente)
- Note rettifica evidenziate con barra arancione
- 5 pulsanti icona: eye (anteprima PDF) + download + pencil (rettifica con dialog)
  + thumbs-up AMMESSA + thumbs-down RESPINTA
- Thumbs up/down = ammissione/rifiuto rapido senza rettifica
- Pencil = dialog con dichiarato readonly + verificato editabile + note obbligatorie -> PARZIALE

VERIFICA ULA:
- Stesso pattern: eye/download/pencil/up/down
- Rettifica FTE (0-1) con note

VERIFICA DOCUMENTI:
- eye/download/thumbs-up VALIDO
- clock SCADUTO (apre dialog con motivazione)
- thumbs-down NON_VALIDO (apre dialog con motivazione)

VERBALE ISTRUTTORIA finale (visibile in UNDER_REVIEW/AWAITING_AMENDMENT):
- 3 checkbox: documentazione completa, ULA>1, erogato in range
- Textarea note sintetiche con save onBlur

Approva disabilitato finché tutte le righe hanno status != PENDING.
Anteprima PDF: dialog con placeholder sandbox (file reale sarà in prod).
Download: toast stub (in prod scarica dal storage).
2026-04-18 11:03:15 +02:00
BFLOWS Sandbox
115f31bdef feat(rendicontazione): lato istruttore - queue + review + soccorso istruttorio
Backend (rendicontazione-api):
- 4 nuove colonne su remission_practice: assigned_instructor_id, reviewed_at,
  reviewed_by, rejection_reason, approved_remission
- Nuova tabella remission_amendment_request (id, practice_id, request_text,
  scope jsonb, deadline, status AWAITING/RESPONSE_RECEIVED/CLOSED/EXPIRED,
  response_text, audit cols)
- Router instructor.py con 8 endpoint:
  GET /instructor/queue (SUBMITTED pool + UNDER_REVIEW/AWAITING_AMENDMENT assigned,
  o tutto se manager/superadmin)
  GET /instructor/{id} (practice + gate_check + amendments)
  POST /instructor/{id}/claim (SUBMITTED -> UNDER_REVIEW)
  POST /instructor/{id}/approve (approved_remission opz, default = remission_due calcolato)
  POST /instructor/{id}/reject (rejection_reason min 10 char)
  POST /instructor/{id}/amendment (crea soccorso: request_text + deadline)
  POST /instructor/{id}/amendment/{aid}/close (chiude soccorso, pratica torna UNDER_REVIEW)
  POST /instructor/{id}/amendment/{aid}/respond-beneficiary (benef risponde)
- GET /{id} ora ritorna anche amendments (per beneficiario)

Frontend:
- Pagina IstruttoriaQueue (125 righe): coda pratiche con stato, istruttore
  assegnato, erogato, remission_due calcolata, azioni contestuali
- Pagina IstruttoriaPratica (483 righe): dettaglio pratica readonly per istruttore,
  riepilogo esteso, amendments panel con chiudi, gate check, fatture/ULA/docs,
  3 Dialog per approva/respingi/soccorso
- PraticaRendicontazioneEdit esteso con sezione 'Richieste di soccorso istruttorio'
  visibile al beneficiario + Dialog rispondi con request_text dell'istruttore
- Sidebar: voce 'Istruttoria rendicontazioni' per EVALUATE_APPLICATIONS
  (pre_instructor + instructor_manager)
- Routes /istruttoria e /istruttoria/:id con gate sui tre ruoli

Test end-to-end OK: benef crea+submit, istruttore claim+amendment, benef risponde,
istruttore chiude+approva -> APPROVED remission 8500 EUR su NAPOLI SAS (erogato 17000).

Utenti sandbox creati:
- istruttore@sandbox.local / istruttore123 (ROLE_PRE_INSTRUCTOR)
- manager@sandbox.local / manager123 (ROLE_INSTRUCTOR_MANAGER)
2026-04-18 10:15:22 +02:00
BFLOWS Sandbox
9c483ade34 feat(rendicontazione): lato beneficiario - lista pratiche + compilazione + submit
- Nuova pagina RendicontazioniMie: dashboard beneficiario con pratiche esistenti
  + applications CONTRACT_SIGNED ready_to_start in tabella unificata
- Nuova pagina PraticaRendicontazioneEdit: form compilazione completo
  + riepilogo finanziario live (erogato, totale, cap, remissione spettante)
  + requisiti per invio con semafori live (gate check refresh on mount)
  + sezione regime IVA con update inline
  + fatture per categoria con dialog add + tabella + delete (per B1/B2/B3)
  + dipendenti ULA con dialog add (CF, contratto, FTE, periodo, allegato)
  + documenti richiesti con upload simulato (prompt nome file)
  + submit con confermazione, disabilitato finche' gate non passa
- Nuova pagina DevSwitchUser: impersonate sandbox-only per superadmin
- Voce sidebar "Le mie rendicontazioni" per ROLE_BENEFICIARY
- Voce sidebar "Dev: cambia utente" per ROLE_SUPER_ADMIN
- Service esteso con 12 metodi pratiche + impersonate
2026-04-18 09:50:53 +02:00
BFLOWS Sandbox
8888e0326d feat(rendicontazione): editor schema con form strutturato + dashboard + integrazione microservizio
- Aggiunta voce 'Rendicontazione' in AppSidebar (id 21, icon pi-receipt)
- Nuova pagina RendicontazioneHome: dashboard con tabella bandi + stato schema
  (Non creato / Bozza / Pubblicato) + azioni Crea/Modifica per ciascuno
- Nuova pagina BandoRendicontazioneSchemaEdit: form strutturato 6 sezioni
  (importi/periodo, IVA, categorie, ULA, documenti, regole gate) con
  salva bozza + pubblica, read-only dopo pubblicazione
- Nuovo service modules/rendicontazione/service/rendicontazioneService.js
  (client fetch verso rendicontazione-api, JWT dallo store Zustand)
- 2 nuove route /rendicontazione e /bandi/:id/rendicontazione-schema
  (gate su ROLE_SUPER_ADMIN)
- Bottone 'Schema rendicontazione' aggiunto in BandoEdit come shortcut
- Patch NotificationsSidebar per disabilitare WSS se REACT_APP_ENABLE_WEBSOCKET=0
  (evita errori CORS in sandbox senza RabbitMQ)

UI coerente col codebase: appPage/appPageSection/appForm/appForm__cols/
fieldsRepeater, p-fluid per width input, h1+p in header con border-left
2026-04-18 09:37:08 +02:00
16 changed files with 5880 additions and 0 deletions

View File

@@ -174,6 +174,10 @@ const NotificationsSidebar = () => {
} }
const connectWebSocket = () => { const connectWebSocket = () => {
// BFLOWS: consenti di disabilitare WSS via env (sandbox senza RabbitMQ)
if (process.env.REACT_APP_ENABLE_WEBSOCKET === '0') {
return;
}
socket.current = new SockJS(socketUrl, null, { socket.current = new SockJS(socketUrl, null, {
transports: [ transports: [
'websocket', 'websocket',

View File

@@ -27,6 +27,27 @@ const AppSidebar = () => {
id: 2, id: 2,
enable: intersection(permissions, ['MANAGE_TENDERS']).length enable: intersection(permissions, ['MANAGE_TENDERS']).length
}, },
{
label: __('Istruttoria rendicontazioni', 'gepafin'),
icon: 'pi pi-check-square',
href: '/istruttoria',
id: 12,
enable: intersection(permissions, ['EVALUATE_APPLICATIONS']).length
},
{
label: __('Rendicontazione', 'gepafin'),
icon: 'pi pi-receipt',
href: '/rendicontazione',
id: 21,
enable: intersection(permissions, ['MANAGE_TENDERS']).length
},
{
label: __('Dev: cambia utente', 'gepafin'),
icon: 'pi pi-user-edit',
href: '/dev-switch-user',
id: 99,
enable: intersection(permissions, ['MANAGE_USERS']).length
},
{ {
label: __('Domande in lavorazione', 'gepafin'), label: __('Domande in lavorazione', 'gepafin'),
icon: 'pi pi-file', icon: 'pi pi-file',
@@ -83,6 +104,13 @@ const AppSidebar = () => {
id: 10, id: 10,
enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length
}, },
{
label: __('Le mie rendicontazioni', 'gepafin'),
icon: 'pi pi-receipt',
href: '/rendicontazioni',
id: 11,
enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length
},
{ {
label: __('Archivio domande', 'gepafin'), label: __('Archivio domande', 'gepafin'),
icon: 'pi pi-briefcase', icon: 'pi pi-briefcase',

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,114 @@
import React, { useEffect, useState, useRef } from 'react';
import { __ } from '@wordpress/i18n';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { ProgressSpinner } from 'primereact/progressspinner';
import RendicontazioneService from '../service/rendicontazioneService';
/**
* Dialog full-height per preview inline PDF/immagini.
* Fetcha il blob dal microservizio (Authorization header), crea object URL,
* lo monta in iframe. Revoca l'URL alla chiusura.
*
* Props:
* visible boolean
* onHide () => void
* entityType 'invoice' | 'ula' | 'document'
* entityId UUID
* title stringa titolo dialog
* filename nome file da mostrare e usare per download
*/
const FilePreviewDialog = ({ visible, onHide, entityType, entityId, title, filename }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [objectUrl, setObjectUrl] = useState(null);
const currentUrlRef = useRef(null);
useEffect(() => {
// cleanup url precedente
if (currentUrlRef.current) {
URL.revokeObjectURL(currentUrlRef.current);
currentUrlRef.current = null;
}
if (!visible || !entityType || !entityId) {
setObjectUrl(null);
setError(null);
return;
}
setLoading(true);
setError(null);
RendicontazioneService.fetchEntityFileBlob(entityType, entityId, true,
({ objectUrl }) => {
currentUrlRef.current = objectUrl;
setObjectUrl(objectUrl);
setLoading(false);
},
(err) => {
setError(err.detail || __('Errore caricamento file', 'gepafin'));
setLoading(false);
}
);
return () => {
if (currentUrlRef.current) {
URL.revokeObjectURL(currentUrlRef.current);
currentUrlRef.current = null;
}
};
}, [visible, entityType, entityId]);
const onDownload = () => {
RendicontazioneService.downloadEntityFile(entityType, entityId,
(err) => setError(err.detail || __('Errore download', 'gepafin'))
);
};
const footer = (
<div className="flex justify-content-between">
<span className="text-color-secondary">{filename}</span>
<div>
<Button label={__('Scarica', 'gepafin')} icon="pi pi-download"
className="p-button-sm p-button-outlined"
onClick={onDownload} disabled={!objectUrl} />
<Button label={__('Chiudi', 'gepafin')} icon="pi pi-times"
className="p-button-sm p-button-text ml-2"
onClick={onHide} />
</div>
</div>
);
return (
<Dialog
header={title || __('Anteprima file', 'gepafin')}
visible={visible}
onHide={onHide}
style={{ width: '90vw', height: '92vh' }}
contentStyle={{ padding: 0, overflow: 'hidden' }}
maximizable
footer={footer}
>
{loading && (
<div className="flex align-items-center justify-content-center" style={{ height: '100%' }}>
<ProgressSpinner />
</div>
)}
{error && !loading && (
<div className="p-4">
<div className="p-error">{error}</div>
</div>
)}
{!loading && !error && objectUrl && (
<iframe
src={objectUrl}
title={filename || 'preview'}
style={{ width: '100%', height: '100%', border: 'none' }}
/>
)}
</Dialog>
);
};
export default FilePreviewDialog;

View File

@@ -0,0 +1,164 @@
import React, { useRef, useState } from 'react';
import { __ } from '@wordpress/i18n';
import { Button } from 'primereact/button';
import { confirmPopup } from 'primereact/confirmpopup';
import RendicontazioneService from '../service/rendicontazioneService';
/**
* Cella compatta per gestire il file allegato a una entita.
*
* Stati:
* nessun file -> bottone "Carica"
* file presente -> chip nome + icone (preview, download, delete)
* upload in corso -> spinner
*
* Props:
* entityType 'invoice' | 'ula' | 'document'
* entityId UUID
* filename nome file corrente (null se nessuno)
* sizeBytes dimensione in byte (opzionale)
* readOnly se true nasconde upload/delete, mostra solo download/preview
* compact se true riduce bottone "Carica"
* onChange (fileMeta | null) => void — callback dopo upload/delete OK
* onPreview () => void — attiva dialog preview esterno
* onError (err) => void
* toastRef ref al Toast per messaggi
*/
const MAX_BYTES = 15 * 1024 * 1024;
const ACCEPT = '.pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png';
const formatSize = (n) => {
if (!n) return '';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
};
const FileUploadCell = ({
entityType, entityId, filename, sizeBytes,
readOnly = false, compact = false,
onChange, onPreview, onError, toastRef
}) => {
const inputRef = useRef(null);
const [uploading, setUploading] = useState(false);
const triggerPick = () => inputRef.current && inputRef.current.click();
const onFileChange = (e) => {
const f = e.target.files && e.target.files[0];
e.target.value = ''; // reset per riupload stesso nome
if (!f) return;
if (f.size > MAX_BYTES) {
const msg = __('File troppo grande (max 15 MB)', 'gepafin');
toastRef && toastRef.current && toastRef.current.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: msg });
onError && onError({ detail: msg });
return;
}
setUploading(true);
RendicontazioneService.uploadEntityFile(entityType, entityId, f,
(resp) => {
setUploading(false);
toastRef && toastRef.current && toastRef.current.show({
severity: 'success', summary: __('File caricato', 'gepafin'),
detail: resp.data && resp.data.filename_original });
onChange && onChange(resp.data);
},
(err) => {
setUploading(false);
const msg = err.detail || __('Errore upload', 'gepafin');
toastRef && toastRef.current && toastRef.current.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: msg });
onError && onError(err);
}
);
};
const onDownloadClick = () => {
RendicontazioneService.downloadEntityFile(entityType, entityId,
(err) => {
const msg = err.detail || __('Errore download', 'gepafin');
toastRef && toastRef.current && toastRef.current.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: msg });
}
);
};
const onDeleteClick = (ev) => {
confirmPopup({
target: ev.currentTarget,
message: __('Eliminare il file allegato?', 'gepafin'),
icon: 'pi pi-exclamation-triangle',
acceptLabel: __('Elimina', 'gepafin'),
rejectLabel: __('Annulla', 'gepafin'),
acceptClassName: 'p-button-danger p-button-sm',
rejectClassName: 'p-button-text p-button-sm',
accept: () => {
RendicontazioneService.deleteEntityFile(entityType, entityId,
() => {
toastRef && toastRef.current && toastRef.current.show({
severity: 'info', summary: __('File eliminato', 'gepafin') });
onChange && onChange(null);
},
(err) => {
const msg = err.detail || __('Errore eliminazione', 'gepafin');
toastRef && toastRef.current && toastRef.current.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: msg });
}
);
}
});
};
if (!filename) {
if (readOnly) {
return <span className="text-color-secondary text-sm">{__('— non caricato —', 'gepafin')}</span>;
}
return (
<>
<Button
icon={uploading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'}
label={compact ? null : __('Carica', 'gepafin')}
className={compact ? 'p-button-sm p-button-text' : 'p-button-sm p-button-outlined'}
disabled={uploading}
onClick={triggerPick}
tooltip={__('Carica PDF/JPG/PNG (max 15 MB)', 'gepafin')}
tooltipOptions={{ position: 'top' }}
/>
<input type="file" ref={inputRef} accept={ACCEPT}
style={{ display: 'none' }} onChange={onFileChange} />
</>
);
}
return (
<div className="flex align-items-center gap-1" style={{ flexWrap: 'nowrap' }}>
<div className="flex align-items-center" style={{ minWidth: 0, flex: 1 }}>
<i className="pi pi-file-pdf text-primary mr-1" />
<span className="text-sm" style={{
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 200
}} title={filename}>
{filename}
</span>
{sizeBytes ? <span className="text-color-secondary text-xs ml-1">({formatSize(sizeBytes)})</span> : null}
</div>
<Button icon="pi pi-eye" className="p-button-text p-button-sm"
tooltip={__('Anteprima', 'gepafin')} tooltipOptions={{ position: 'top' }}
onClick={() => onPreview && onPreview()} />
<Button icon="pi pi-download" className="p-button-text p-button-sm"
tooltip={__('Scarica', 'gepafin')} tooltipOptions={{ position: 'top' }}
onClick={onDownloadClick} />
{!readOnly && (
<>
<Button icon="pi pi-refresh" className="p-button-text p-button-sm p-button-secondary"
tooltip={__('Sostituisci', 'gepafin')} tooltipOptions={{ position: 'top' }}
disabled={uploading} onClick={triggerPick} />
<Button icon="pi pi-trash" className="p-button-text p-button-sm p-button-danger"
tooltip={__('Elimina', 'gepafin')} tooltipOptions={{ position: 'top' }}
disabled={uploading} onClick={onDeleteClick} />
<input type="file" ref={inputRef} accept={ACCEPT}
style={{ display: 'none' }} onChange={onFileChange} />
</>
)}
</div>
);
};
export default FileUploadCell;

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

@@ -0,0 +1,752 @@
import React, { useEffect, useState, useRef, useMemo } from 'react';
import { __ } from '@wordpress/i18n';
import { useNavigate, useParams } from 'react-router-dom';
// store
import { useStoreValue } from '../../../store';
// components
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { Tag } from 'primereact/tag';
import { Skeleton } from 'primereact/skeleton';
import { ConfirmPopup, confirmPopup } from 'primereact/confirmpopup';
import { InputText } from 'primereact/inputtext';
import { InputNumber } from 'primereact/inputnumber';
import { InputSwitch } from 'primereact/inputswitch';
import { MultiSelect } from 'primereact/multiselect';
import { Dropdown } from 'primereact/dropdown';
import { Calendar } from 'primereact/calendar';
import { InputTextarea } from 'primereact/inputtextarea';
import BlockingOverlay from '../../../components/BlockingOverlay';
// api
import RendicontazioneService from '../service/rendicontazioneService';
import SchemaTemplatePicker from '../components/SchemaTemplatePicker';
import BandoService from '../../../service/bando-service';
// ---------- costanti ----------
const IVA_REGIMES = [
{ value: 'ORDINARIO', label: 'Ordinario' },
{ value: 'FORFETTARIO', label: 'Forfettario' },
{ value: 'ESENTE', label: 'Esente' }
];
const AMOUNT_BASIS_OPTIONS = [
{ value: 'imponibile_always', label: 'Solo imponibile (sempre)' },
{ value: 'imponibile_only_ordinario', label: 'Imponibile in ordinario, totale in forfettario' },
{ value: 'totale_always', label: 'Totale IVA inclusa (sempre)' }
];
const PERIOD_START_RULES = [
{ value: 'erogato_date', label: 'Data di erogazione del finanziamento' },
{ value: 'contract_signed_date', label: 'Data firma contratto' },
{ value: 'custom', label: 'Data personalizzata' }
];
const ULA_DOC_TYPES = [
{ value: 'LUL', label: 'Libro Unico del Lavoro (LUL)' },
{ value: 'GESTIONALE_PAGHE', label: 'Estratto gestionale paghe' },
{ value: 'DICHIARAZIONE_CDL', label: 'Dichiarazione Consulente del Lavoro' },
{ value: 'ALTRO', label: 'Altro documento di supporto' }
];
// ---------- helpers JSON <-> form ----------
const schemaJsonToForm = (j) => {
if (!j || !j.sections) return null;
const general = j.sections.find(s => s.type === 'static_fields') || {};
const expenses = j.sections.find(s => s.type === 'category_grid') || {};
const ula = j.sections.find(s => s.type === 'ula_block') || {};
const docs = j.sections.find(s => s.type === 'document_checklist') || {};
const gate = j.gate_rules || {};
const ivaField = (general.fields || []).find(f => f.id === 'iva_regime');
const ivaAllowed = ivaField && ivaField.options
? ivaField.options.map(o => typeof o === 'string' ? o : o.value)
: ['ORDINARIO','FORFETTARIO','ESENTE'];
const parseList = (list) => (list || []).map(x =>
typeof x === 'string' ? { code: x, label: x } : { code: x.code || '', label: x.label || x.code || '' });
return {
amount_min: gate.amount_range?.min ?? 5000,
amount_max: gate.amount_range?.max ?? 25000,
period_start: gate.period_start ? new Date(gate.period_start) : null,
period_end: gate.period_end ? new Date(gate.period_end) : null,
amount_basis: gate.amount_basis || (gate.iva_ordinario_imponibile_only === false ? 'totale_always' : 'imponibile_only_ordinario'),
period_start_rule: gate.period_start_rule ?? 'erogato_date',
iva_regimes_allowed: ivaAllowed,
iva_ordinario_imponibile_only: gate.iva_ordinario_imponibile_only ?? true,
categories: (expenses.categories || []).map(c => ({
code: c.code || '', label: c.label || '',
description: c.description || '', cap_amount: c.cap_amount ?? null
})),
ula_enabled: ula.enabled ?? false,
ula_threshold: ula.threshold ?? 1.0,
ula_period_start_rule: ula.period_start_rule ?? 'erogato_date',
ula_period_end: ula.period_end ? new Date(ula.period_end) : null,
ula_supporting_doc_required: ula.supporting_doc_required ?? true,
ula_supporting_doc_types: (ula.supporting_doc_types || []).map(t => typeof t === 'string' ? t : t.code),
docs_required: parseList(docs.required_types),
cap_pct_erogato: gate.cap_pct_erogato != null ? Math.round(gate.cap_pct_erogato * 100) : 50,
cap_absolute: gate.cap_absolute ?? 12500,
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_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,
}))
};
};
const formToSchemaJson = (f, base = null) => {
const orig = base || {};
const fmtDate = (d) => d ? (typeof d === 'string' ? d : d.toISOString().slice(0, 10)) : null;
return {
version: orig.version || '1.0',
template_id: orig.template_id || 'CUSTOM',
template_label: orig.template_label || 'Schema personalizzato',
sections: [
{
type: 'static_fields', id: 'general', label: 'Dati generali',
fields: [{
id: 'iva_regime', type: 'select', label: 'Regime IVA', required: true,
options: IVA_REGIMES.filter(o => f.iva_regimes_allowed.includes(o.value))
}]
},
{
type: 'category_grid', id: 'expenses', label: 'Spese ammissibili per categoria',
categories: f.categories,
invoice_schema: { required_fields: ['invoice_number','invoice_date','payment_date','supplier_name','supplier_vat','description','taxable','vat','total','pdf'] }
},
{
type: 'ula_block', id: 'ula', label: 'Calcolo ULA',
enabled: f.ula_enabled, threshold: f.ula_threshold,
period_start_rule: f.ula_period_start_rule,
period_end: fmtDate(f.ula_period_end),
supporting_doc_required: f.ula_supporting_doc_required,
supporting_doc_types: ULA_DOC_TYPES
.filter(t => f.ula_supporting_doc_types.includes(t.value))
.map(t => ({ code: t.value, label: t.label }))
},
{
type: 'document_checklist', id: 'docs', label: 'Documenti richiesti',
required_types: f.docs_required
}
],
gate_rules: {
amount_range: { min: f.amount_min, max: f.amount_max },
cap_pct_erogato: f.cap_pct_erogato / 100,
cap_absolute: f.cap_absolute,
iva_ordinario_imponibile_only: f.iva_ordinario_imponibile_only,
period_start_rule: f.period_start_rule,
period_start: fmtDate(f.period_start),
period_end: fmtDate(f.period_end),
amount_basis: f.amount_basis,
require_at_least_one_invoice_per_nonzero_category: f.require_invoice_per_category,
require_ula_above_threshold: f.require_ula_above_threshold,
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
};
};
const BandoRendicontazioneSchemaEdit = () => {
const { id } = useParams();
const navigate = useNavigate();
const isAsyncRequest = useStoreValue('isAsyncRequest');
const callId = parseInt(id);
const [bando, setBando] = useState(null);
const [bandoLoading, setBandoLoading] = useState(true);
const [schemaRecord, setSchemaRecord] = useState(null);
const [schemaLoading, setSchemaLoading] = useState(true);
const [form, setForm] = useState(null);
const [dirty, setDirty] = useState(false);
const toast = useRef(null);
// ---------- load ----------
const loadBando = () => {
setBandoLoading(true);
BandoService.getBando(callId,
(r) => { setBando(r?.data || null); setBandoLoading(false); },
() => setBandoLoading(false));
};
const loadSchema = () => {
setSchemaLoading(true);
RendicontazioneService.getSchemaByCallId(callId,
(resp) => {
const rec = resp?.data || null;
setSchemaRecord(rec);
setForm(rec ? schemaJsonToForm(rec.schema_json) : null);
setDirty(false);
setSchemaLoading(false);
},
(err) => {
if (err?.status === 404) { setSchemaRecord(null); setForm(null); }
else toast.current?.show({ severity: 'error', summary: __('Errore caricamento schema','gepafin'), detail: err?.detail });
setSchemaLoading(false);
});
};
useEffect(() => {
if (!isNaN(callId)) { loadBando(); loadSchema(); }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [callId]);
// ---------- updates ----------
const update = (patch) => { setForm(p => ({ ...p, ...patch })); setDirty(true); };
const updateCategory = (idx, patch) => {
setForm(p => ({ ...p, categories: p.categories.map((c,i) => i===idx ? {...c, ...patch} : c) }));
setDirty(true);
};
const addCategory = () => {
setForm(p => ({ ...p, categories: [...p.categories, { code:'', label:'', description:'', cap_amount:null }] }));
setDirty(true);
};
const removeCategory = (idx) => {
setForm(p => ({ ...p, categories: p.categories.filter((_,i) => i!==idx) }));
setDirty(true);
};
const updateDoc = (idx, patch) => {
setForm(p => ({ ...p, docs_required: p.docs_required.map((d,i) => i===idx ? {...d,...patch} : d) }));
setDirty(true);
};
const addDoc = () => {
setForm(p => ({ ...p, docs_required: [...p.docs_required, { code:'', label:'' }] }));
setDirty(true);
};
const removeDoc = (idx) => {
setForm(p => ({ ...p, docs_required: p.docs_required.filter((_,i) => i!==idx) }));
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 ----------
const handleInitializeRestart = (e) => {
confirmPopup({
target: e.currentTarget,
message: __('Inizializzo lo schema con il template RE-START? Sarà modificabile finché non verrà pubblicato.','gepafin'),
icon: 'pi pi-info-circle',
acceptLabel: __('Inizializza','gepafin'), rejectLabel: __('Annulla','gepafin'),
accept: () => RendicontazioneService.initializeRestartTemplate(callId,
() => { toast.current?.show({severity:'success', summary: __('Schema inizializzato','gepafin')}); loadSchema(); },
(err) => toast.current?.show({severity:'error', summary:__('Inizializzazione fallita','gepafin'), detail: err?.detail}))
});
};
const handleSave = () => {
const newJson = formToSchemaJson(form, schemaRecord?.schema_json);
RendicontazioneService.updateSchema(callId, newJson,
(resp) => {
toast.current?.show({severity:'success', summary: __('Schema salvato','gepafin')});
setSchemaRecord(resp?.data);
setForm(schemaJsonToForm(resp?.data?.schema_json));
setDirty(false);
},
(err) => toast.current?.show({severity:'error', summary:__('Salvataggio fallito','gepafin'), detail: err?.detail}));
};
const handlePublish = (e) => {
confirmPopup({
target: e.currentTarget,
message: __('Dopo la pubblicazione lo schema non sarà più modificabile e diventerà visibile ai beneficiari. Confermi?','gepafin'),
icon: 'pi pi-exclamation-triangle',
acceptLabel: __('Pubblica','gepafin'), rejectLabel: __('Annulla','gepafin'),
acceptClassName: 'p-button-success',
accept: () => RendicontazioneService.publishSchema(callId,
(resp) => { toast.current?.show({severity:'success', summary:__('Schema pubblicato','gepafin')}); setSchemaRecord(resp?.data); },
(err) => toast.current?.show({severity:'error', summary:__('Pubblicazione fallita','gepafin'), detail: err?.detail}))
});
};
// ---------- render ----------
const isPublished = schemaRecord?.status === 'PUBLISHED';
const readOnly = isPublished;
const hasSchema = !!schemaRecord;
const statusTag = useMemo(() => {
if (!hasSchema) return <Tag severity="info" value={__('Non creato','gepafin')} />;
if (isPublished) return <Tag severity="success" value={__('Pubblicato','gepafin')} />;
return <Tag severity="warning" value={__('Bozza','gepafin')} />;
}, [hasSchema, isPublished]);
return (
<div className="appPage">
<Toast ref={toast} />
<ConfirmPopup />
<BlockingOverlay isBlocked={isAsyncRequest} />
{/* HEADER — flex column, border-left */}
<div className="appPage__pageHeader">
<h1>{__('Schema rendicontazione','gepafin')}</h1>
<p>
{bandoLoading
? <Skeleton width="20rem" height="1.2rem" />
: <>
<span className="companyName">{(bando && bando.name) || `Bando #${callId}`}</span>
<span style={{ marginLeft: '1rem' }}>{statusTag}</span>
</>}
</p>
</div>
<div className="appPage__spacer"></div>
{/* ACTIONS — torna indietro + salva/pubblica */}
<div className="appPageSection">
<div className="appPageSection__actions">
<Button type="button" outlined icon="pi pi-arrow-left"
label={__('Indietro','gepafin')} onClick={() => navigate('/rendicontazione')} />
{hasSchema && !isPublished && (
<>
<Button type="button" icon="pi pi-save" iconPos="right"
label={__('Salva bozza','gepafin')} onClick={handleSave} disabled={!dirty} />
<Button type="button" icon="pi pi-check-circle" iconPos="right" severity="success"
label={__('Pubblica','gepafin')} onClick={handlePublish} disabled={dirty} />
</>
)}
</div>
</div>
<div className="appPage__spacer"></div>
{/* CONTENT */}
{schemaLoading && (
<div className="appPageSection">
<Skeleton width="100%" height="12rem" />
</div>
)}
{!schemaLoading && !hasSchema && (
<div className="appPageSection">
<SchemaTemplatePicker
callId={callId}
onInitialized={(data) => {
setSchemaRecord(data);
setForm(schemaJsonToForm(data.schema_json));
setDirty(false);
toast.current?.show({
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>
)}
{!schemaLoading && hasSchema && form && (
<form className="appForm p-fluid" onSubmit={(e) => e.preventDefault()}>
{/* 1 - IMPORTI E PERIODO */}
<div className="appPageSection">
<h2>{__('1. Importi ammissibili e periodo','gepafin')}</h2>
<div className="appForm__cols">
<div className="appForm__field">
<label>{__('Importo minimo erogato','gepafin')}</label>
<InputNumber value={form.amount_min} onValueChange={(e) => update({amount_min: e.value})}
mode="currency" currency="EUR" locale="it-IT" disabled={readOnly} />
</div>
<div className="appForm__field">
<label>{__('Importo massimo erogato','gepafin')}</label>
<InputNumber value={form.amount_max} onValueChange={(e) => update({amount_max: e.value})}
mode="currency" currency="EUR" locale="it-IT" disabled={readOnly} />
</div>
</div>
<div className="appForm__cols">
<div className="appForm__field">
<label>{__('Inizio periodo — regola','gepafin')}</label>
<Dropdown value={form.period_start_rule}
onChange={(e) => update({period_start_rule: e.value})}
options={PERIOD_START_RULES} disabled={readOnly} />
</div>
<div className="appForm__field">
<label>{__('Inizio periodo — data (se fissa)','gepafin')}</label>
<Calendar value={form.period_start} onChange={(e) => update({period_start: e.value})}
dateFormat="dd/mm/yy" showIcon disabled={readOnly} />
<small className="text-color-secondary">{__("Usata dalla verifica date fatture. Compila se la regola non è 'data erogazione'.",'gepafin')}</small>
</div>
<div className="appForm__field">
<label>{__('Fine periodo','gepafin')}</label>
<Calendar value={form.period_end} onChange={(e) => update({period_end: e.value})}
dateFormat="dd/mm/yy" showIcon disabled={readOnly} />
</div>
</div>
<div className="appForm__field" style={{maxWidth: '600px'}}>
<label>{__('Base di calcolo ammissibile','gepafin')}</label>
<Dropdown value={form.amount_basis}
onChange={(e) => update({amount_basis: e.value})}
options={AMOUNT_BASIS_OPTIONS} disabled={readOnly} />
<small className="text-color-secondary">
{__("Determina su quale importo delle fatture si calcola la remissione. La norma del bando può prevedere regimi diversi.", 'gepafin')}
</small>
</div>
</div>
<div className="appPage__spacer"></div>
{/* 2 - IVA */}
<div className="appPageSection">
<h2>{__('2. Regime IVA','gepafin')}</h2>
<div className="appForm__field">
<label>{__('Regimi IVA consentiti','gepafin')}</label>
<MultiSelect value={form.iva_regimes_allowed} options={IVA_REGIMES}
onChange={(e) => update({iva_regimes_allowed: e.value})}
disabled={readOnly} display="chip" placeholder={__('Seleziona regimi','gepafin')} />
</div>
<div className="appForm__field">
<div className="appForm__row">
<InputSwitch checked={form.iva_ordinario_imponibile_only}
onChange={(e) => update({iva_ordinario_imponibile_only: e.value})} disabled={readOnly} />
<label style={{ cursor: 'pointer' }}
onClick={() => !readOnly && update({iva_ordinario_imponibile_only: !form.iva_ordinario_imponibile_only})}>
{__('Regime ordinario: solo imponibile rendicontabile','gepafin')}
</label>
</div>
<small>{__('Se attivo, in regime ordinario l\'IVA non viene considerata rendicontabile — vale solo la base imponibile della fattura.','gepafin')}</small>
</div>
</div>
<div className="appPage__spacer"></div>
{/* 3 - CATEGORIE */}
<div className="appPageSection">
<h2>{__('3. Categorie di spesa ammissibili','gepafin')} <span style={{fontWeight:400, color:'var(--text-color-secondary)', fontSize:'0.9em'}}>({form.categories.length})</span></h2>
<div className="fieldsRepeater">
{form.categories.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 || `#${i+1}`} {c.label || __('(senza nome)','gepafin')}</strong>
{!readOnly && (
<Button type="button" icon="pi pi-trash" severity="danger" outlined
size="small" onClick={() => removeCategory(i)}
tooltip={__('Rimuovi categoria','gepafin')} tooltipOptions={{position:'top'}} />
)}
</div>
<div className="appForm__cols">
<div className="appForm__field">
<label>{__('Codice','gepafin')}</label>
<InputText value={c.code} onChange={(e) => updateCategory(i,{code:e.target.value})}
placeholder="B1" disabled={readOnly} />
</div>
<div className="appForm__field">
<label>{__('Cap importo (opzionale)','gepafin')}</label>
<InputNumber value={c.cap_amount}
onValueChange={(e) => updateCategory(i,{cap_amount:e.value})}
mode="currency" currency="EUR" locale="it-IT" disabled={readOnly} placeholder="—" />
</div>
</div>
<div className="appForm__field">
<label>{__('Nome categoria','gepafin')}</label>
<InputText value={c.label}
onChange={(e) => updateCategory(i,{label:e.target.value})} disabled={readOnly} />
</div>
<div className="appForm__field">
<label>{__('Descrizione','gepafin')}</label>
<InputTextarea value={c.description}
onChange={(e) => updateCategory(i,{description:e.target.value})}
rows={2} disabled={readOnly} autoResize />
</div>
</div>
))}
</div>
{!readOnly && (
<div style={{ marginTop: '1rem' }}>
<Button type="button" icon="pi pi-plus" iconPos="right" outlined
label={__('Aggiungi categoria','gepafin')} onClick={addCategory} />
</div>
)}
</div>
<div className="appPage__spacer"></div>
{/* 4 - ULA */}
<div className="appPageSection">
<h2>{__('4. Calcolo ULA (incremento occupazione)','gepafin')}</h2>
<div className="appForm__field">
<div className="appForm__row">
<InputSwitch checked={form.ula_enabled}
onChange={(e) => update({ula_enabled: e.value})} disabled={readOnly} />
<label style={{ cursor: 'pointer' }}
onClick={() => !readOnly && update({ula_enabled: !form.ula_enabled})}>
{__('Calcolo ULA richiesto per questo bando','gepafin')}
</label>
</div>
</div>
{form.ula_enabled && (
<>
<div className="appForm__cols">
<div className="appForm__field">
<label>{__('Soglia minima di incremento','gepafin')}</label>
<InputNumber value={form.ula_threshold}
onValueChange={(e) => update({ula_threshold: e.value})}
mode="decimal" minFractionDigits={1} maxFractionDigits={2} min={0} disabled={readOnly} />
</div>
<div className="appForm__field">
<label>{__('Periodo fine ULA','gepafin')}</label>
<Calendar value={form.ula_period_end}
onChange={(e) => update({ula_period_end: e.value})}
dateFormat="dd/mm/yy" showIcon disabled={readOnly} />
</div>
</div>
<div className="appForm__field">
<div className="appForm__row">
<InputSwitch checked={form.ula_supporting_doc_required}
onChange={(e) => update({ula_supporting_doc_required: e.value})} disabled={readOnly} />
<label style={{ cursor: 'pointer' }}
onClick={() => !readOnly && update({ula_supporting_doc_required: !form.ula_supporting_doc_required})}>
{__('Allegato di supporto obbligatorio','gepafin')}
</label>
</div>
</div>
{form.ula_supporting_doc_required && (
<div className="appForm__field">
<label>{__('Tipi di documento ammessi','gepafin')}</label>
<MultiSelect value={form.ula_supporting_doc_types} options={ULA_DOC_TYPES}
onChange={(e) => update({ula_supporting_doc_types: e.value})}
disabled={readOnly} display="chip" placeholder={__('Seleziona tipi','gepafin')} />
</div>
)}
</>
)}
</div>
<div className="appPage__spacer"></div>
{/* 5 - DOCUMENTI */}
<div className="appPageSection">
<h2>{__('5. Documenti richiesti','gepafin')} <span style={{fontWeight:400, color:'var(--text-color-secondary)', fontSize:'0.9em'}}>({form.docs_required.length})</span></h2>
<p style={{ color:'var(--text-color-secondary)', marginTop: 0 }}>
{__('I documenti già in regola nel repository della Company saranno riutilizzati automaticamente. Solo quelli scaduti o mancanti richiederanno caricamento.','gepafin')}
</p>
<div className="fieldsRepeater">
{form.docs_required.map((d, i) => (
<div key={i} className="fieldsRepeater__panel" style={{ padding:'0.75rem 1rem', border:'1px solid var(--surface-border)', borderRadius:'6px', background:'var(--surface-50)' }}>
<div className="appForm__cols">
<div className="appForm__field">
<label>{__('Codice','gepafin')}</label>
<InputText value={d.code} onChange={(e) => updateDoc(i,{code:e.target.value})}
placeholder="DURC" disabled={readOnly} />
</div>
<div className="appForm__field">
<label>{__('Etichetta visibile al beneficiario','gepafin')}</label>
<InputText value={d.label} onChange={(e) => updateDoc(i,{label:e.target.value})} disabled={readOnly} />
</div>
</div>
{!readOnly && (
<div style={{ textAlign: 'right', marginTop:'0.5rem' }}>
<Button type="button" icon="pi pi-trash" severity="danger" outlined size="small"
onClick={() => removeDoc(i)} label={__('Rimuovi','gepafin')} />
</div>
)}
</div>
))}
</div>
{!readOnly && (
<div style={{ marginTop: '1rem' }}>
<Button type="button" icon="pi pi-plus" iconPos="right" outlined
label={__('Aggiungi documento','gepafin')} onClick={addDoc} />
</div>
)}
</div>
<div className="appPage__spacer"></div>
{/* 6 - REGOLE */}
<div className="appPageSection">
<h2>{__('6. Regole di validazione (gate pre-submit)','gepafin')}</h2>
<div className="appForm__cols">
<div className="appForm__field">
<label>{__('Cap remissione (% erogato)','gepafin')}</label>
<InputNumber value={form.cap_pct_erogato}
onValueChange={(e) => update({cap_pct_erogato: e.value})}
suffix=" %" min={0} max={100} disabled={readOnly} />
</div>
<div className="appForm__field">
<label>{__('Cap remissione assoluto','gepafin')}</label>
<InputNumber value={form.cap_absolute}
onValueChange={(e) => update({cap_absolute: e.value})}
mode="currency" currency="EUR" locale="it-IT" disabled={readOnly} />
</div>
</div>
<div className="appForm__field">
<div className="appForm__row">
<InputSwitch checked={form.require_invoice_per_category}
onChange={(e) => update({require_invoice_per_category: e.value})} disabled={readOnly} />
<label style={{ cursor: 'pointer' }}
onClick={() => !readOnly && update({require_invoice_per_category: !form.require_invoice_per_category})}>
{__('Richiedi almeno una fattura per ogni categoria con importo > 0','gepafin')}
</label>
</div>
</div>
<div className="appForm__field">
<div className="appForm__row">
<InputSwitch checked={form.require_ula_above_threshold}
onChange={(e) => update({require_ula_above_threshold: e.value})} disabled={readOnly} />
<label style={{ cursor: 'pointer' }}
onClick={() => !readOnly && update({require_ula_above_threshold: !form.require_ula_above_threshold})}>
{__('Richiedi ULA sopra soglia per validare','gepafin')}
</label>
</div>
</div>
<div className="appForm__field">
<div className="appForm__row">
<InputSwitch checked={form.require_all_documents_resolved}
onChange={(e) => update({require_all_documents_resolved: e.value})} disabled={readOnly} />
<label style={{ cursor: 'pointer' }}
onClick={() => !readOnly && update({require_all_documents_resolved: !form.require_all_documents_resolved})}>
{__('Richiedi che tutti i documenti siano in regola','gepafin')}
</label>
</div>
</div>
</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à) */}
{!isPublished && (
<div className="appPageSection">
<div className="appPageSection__actions">
<Button type="button" icon="pi pi-save" iconPos="right"
label={__('Salva bozza','gepafin')} onClick={handleSave} disabled={!dirty} />
<Button type="button" icon="pi pi-check-circle" iconPos="right" severity="success"
label={__('Pubblica','gepafin')} onClick={handlePublish} disabled={dirty} />
</div>
</div>
)}
</form>
)}
</div>
);
};
export default BandoRendicontazioneSchemaEdit;

View File

@@ -0,0 +1,83 @@
import React, { useState, useRef } from 'react';
import { __ } from '@wordpress/i18n';
import { useNavigate } from 'react-router-dom';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { InputText } from 'primereact/inputtext';
import { Toast } from 'primereact/toast';
import { storeSet } from '../../../store';
import RendicontazioneService from '../service/rendicontazioneService';
/**
* Pagina sandbox: permette al superadmin di impersonare un altro utente
* (tipicamente beneficiario) senza passare per SPID. Solo per sviluppo.
*/
const DevSwitchUser = () => {
const navigate = useNavigate();
const toast = useRef(null);
const [email, setEmail] = useState('beneficiario@sandbox.local');
const [loading, setLoading] = useState(false);
const doImpersonate = () => {
setLoading(true);
RendicontazioneService.impersonate(email,
(resp) => {
const data = resp?.data;
if (!data?.token) {
toast.current?.show({ severity: 'error', summary: __('Risposta vuota', 'gepafin') });
setLoading(false);
return;
}
// popola lo store Zustand come dopo il login
storeSet('setAuthData', {
token: data.token,
userData: data.user
});
toast.current?.show({ severity: 'success', summary: __('Ora sei ', 'gepafin') + data.user.email });
// aspetta un tick e ricarica a root
setTimeout(() => window.location.replace('/'), 700);
},
(err) => {
toast.current?.show({ severity: 'error', summary: __('Impersonate fallito', 'gepafin'), detail: err?.detail });
setLoading(false);
}
);
};
return (
<div className="appPage">
<Toast ref={toast} />
<div className="appPage__pageHeader">
<h1>{__('Dev: cambia utente', 'gepafin')}</h1>
<p>{__('Pagina sandbox. Permette di impersonare un utente (es. beneficiario) senza passare per SPID.', 'gepafin')}</p>
</div>
<div className="appPage__spacer"></div>
<div className="appPageSection">
<Card style={{ width: '100%', maxWidth: '500px' }}>
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); doImpersonate(); }}>
<div className="appForm__field">
<label>{__('Email utente da impersonare', 'gepafin')}</label>
<InputText value={email} onChange={(e) => setEmail(e.target.value)} />
<small className="text-color-secondary">
{__('Prova: beneficiario@sandbox.local oppure admin@sandbox.local', 'gepafin')}
</small>
</div>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem' }}>
<Button type="button" outlined label={__('Indietro', 'gepafin')}
onClick={() => navigate('/')} />
<Button type="submit" label={__('Impersona', 'gepafin')} icon="pi pi-user-edit"
loading={loading} severity="warning" />
</div>
</form>
</Card>
</div>
</div>
);
};
export default DevSwitchUser;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,336 @@
import React, { useEffect, useState, useRef } from 'react';
import { __ } from '@wordpress/i18n';
import { useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
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 { storeGet } from '../../../store';
const STATUS_TAGS = {
SUBMITTED: { severity: 'info', label: 'Da prendere in carico' },
UNDER_REVIEW: { severity: 'warning', label: 'In valutazione' },
AWAITING_AMENDMENT: { severity: 'warning', label: 'Soccorso istruttorio' }
};
const euro = (v) => '€ ' + Number(v || 0).toLocaleString('it-IT', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const formatDate = (d) => d ? new Date(d).toLocaleDateString('it-IT') : '—';
const IstruttoriaQueue = () => {
const navigate = useNavigate();
const toast = useRef(null);
const [items, setItems] = useState([]);
const [isManagerFromQueue, setIsManagerFromQueue] = useState(false);
const [loading, setLoading] = useState(true);
// 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);
RendicontazioneService.instructorQueue(
(resp) => {
setItems(resp?.data?.items || []);
setIsManagerFromQueue(!!resp?.data?.manager_view);
setLoading(false);
},
(err) => {
toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail });
setLoading(false);
}
);
};
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) => (
<div>
<strong>{row.call_name || `Bando #${row.call_id}`}</strong>
<div><small className="text-color-secondary">{row.company_name} · pratica #{row.application_id}</small></div>
</div>
);
const statusTpl = (row) => {
const c = STATUS_TAGS[row.status] || { severity: 'secondary', label: row.status };
return <div>
<Tag value={c.label} severity={c.severity} />
{row.open_amendments > 0 && (
<div style={{ marginTop: '4px' }}>
<Tag value={`${row.open_amendments} soccorso aperto`} severity="warning" icon="pi pi-clock" />
</div>
)}
</div>;
};
const submittedTpl = (row) => row.submitted_at ? formatDate(row.submitted_at) : '—';
const erogatoTpl = (row) => <strong>{euro(row.amount_erogato)}</strong>;
const remissionTpl = (row) => row.remission_due != null
? <span style={{ color: 'var(--primary-color)', fontWeight: 600 }}>{euro(row.remission_due)}</span>
: <span className="text-color-secondary"></span>;
const progressTpl = (row) => (
<small className="text-color-secondary">
{row.invoice_count} {__('fatt.', 'gepafin')} · {row.ula_count} {__('dip.', 'gepafin')} · {row.document_count} {__('doc', 'gepafin')}
</small>
);
const actionsTpl = (row) => {
const label = row.status === 'SUBMITTED' ? __('Apri e prendi in carico', 'gepafin') : __('Apri', 'gepafin');
return <Button icon="pi pi-eye" label={label} size="small"
outlined={row.status !== 'SUBMITTED'}
onClick={() => navigate(`/istruttoria/${row.id}`)} />;
};
const assignedTpl = (row) => {
if (!row.assigned_instructor_id) return <span className="text-color-secondary"></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 (
<div className="appPage">
<Toast ref={toast} />
<div className="appPage__pageHeader">
<h1>{__('Coda istruttoria', 'gepafin')}</h1>
<p>
{managerMode
? __('Vista manager: tutte le pratiche inviate con istruttore suggerito e assegnato. Puoi riassegnare le pratiche da qui.', '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>
</div>
<div className="appPage__spacer"></div>
{/* TOGGLE MANAGER VIEW */}
{canUseManagerView && (
<div className="appPageSection">
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<Button icon="pi pi-list"
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>
)}
</Dialog>
</div>
);
};
export default IstruttoriaQueue;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,145 @@
import React, { useEffect, useState, useRef } from 'react';
import { __ } from '@wordpress/i18n';
import { useNavigate } from 'react-router-dom';
// components
import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Card } from 'primereact/card';
import { Tag } from 'primereact/tag';
import { Skeleton } from 'primereact/skeleton';
import { Toast } from 'primereact/toast';
// api
import BandoService from '../../../service/bando-service';
import RendicontazioneService from '../service/rendicontazioneService';
const SCHEMA_STATUS_CONFIG = {
null: { severity: 'info', label: __('Non creato', 'gepafin'), icon: 'pi pi-circle' },
DRAFT: { severity: 'warning', label: __('Bozza', 'gepafin'), icon: 'pi pi-pencil' },
PUBLISHED: { severity: 'success', label: __('Pubblicato', 'gepafin'), icon: 'pi pi-check-circle' }
};
const RendicontazioneHome = () => {
const navigate = useNavigate();
const toast = useRef(null);
const [rows, setRows] = useState([]); // {bando, schema}
const [loading, setLoading] = useState(true);
const loadData = () => {
setLoading(true);
BandoService.getBandiPaginated({ page: 0, size: 100 },
(resp) => {
const bandi = resp?.data?.body || [];
// per ogni bando, tento di caricare lo schema di rendicontazione
const baseRows = bandi.map(b => ({ bando: b, schema: null, schemaLoaded: false }));
setRows(baseRows);
setLoading(false);
// Caricamento schemi in parallelo — update progressivo
bandi.forEach((b, idx) => {
RendicontazioneService.getSchemaByCallId(b.id,
(schemaResp) => {
setRows(prev => prev.map((r, i) => i === idx
? { ...r, schema: schemaResp?.data || null, schemaLoaded: true }
: r));
},
(err) => {
// 404 = schema non ancora creato, tutto ok
setRows(prev => prev.map((r, i) => i === idx
? { ...r, schema: null, schemaLoaded: true }
: r));
}
);
});
},
(err) => {
setLoading(false);
if (toast.current) {
toast.current.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.message || __('Impossibile caricare i bandi', 'gepafin') });
}
}
);
};
useEffect(() => {
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const editSchema = (bandoId) => {
navigate(`/bandi/${bandoId}/rendicontazione-schema`);
};
// --- column templates ---
const bandoNameTpl = (row) => (
<div>
<strong>{row.bando.name || `Bando #${row.bando.id}`}</strong>
{row.bando.descriptionShort && (
<div><small className="text-color-secondary">{row.bando.descriptionShort.slice(0, 80)}{row.bando.descriptionShort.length > 80 ? '…' : ''}</small></div>
)}
</div>
);
const bandoStatusTpl = (row) => (
<Tag value={row.bando.status || '—'} severity={row.bando.status === 'PUBLISH' ? 'success' : 'secondary'} />
);
const schemaStatusTpl = (row) => {
if (!row.schemaLoaded) return <Skeleton width="6rem" height="1.5rem" />;
const key = row.schema ? row.schema.status : null;
const conf = SCHEMA_STATUS_CONFIG[key] || SCHEMA_STATUS_CONFIG[null];
return <Tag icon={conf.icon} value={conf.label} severity={conf.severity} />;
};
const actionsTpl = (row) => {
if (!row.schemaLoaded) return <Skeleton width="8rem" height="2rem" />;
const hasSchema = !!row.schema;
return (
<Button
icon={hasSchema ? 'pi pi-pencil' : 'pi pi-plus-circle'}
label={hasSchema ? __('Modifica', 'gepafin') : __('Crea schema', 'gepafin')}
className={hasSchema ? 'p-button-outlined p-button-sm' : 'p-button-sm'}
severity={hasSchema ? null : 'success'}
onClick={() => editSchema(row.bando.id)}
/>
);
};
return (
<div className="page-wrapper">
<Toast ref={toast} />
<div className="mb-3">
<h2 className="mb-1">{__('Gestione rendicontazione', 'gepafin')}</h2>
<p className="m-0 text-color-secondary">
{__('Configura per ciascun bando lo schema di rendicontazione che i beneficiari vedranno dopo la firma del contratto. Ogni bando ha uno schema: categorie di spesa, regole ULA, documenti richiesti.', 'gepafin')}
</p>
</div>
<Card>
<DataTable
value={rows}
loading={loading}
dataKey="bando.id"
emptyMessage={__('Nessun bando disponibile', 'gepafin')}
paginator={rows.length > 15}
rows={15}
stripedRows
>
<Column field="bando.id" header="ID" style={{ width: '60px' }} />
<Column field="bando.name" header={__('Bando', 'gepafin')} body={bandoNameTpl} />
<Column field="bando.status" header={__('Stato bando', 'gepafin')} body={bandoStatusTpl} style={{ width: '140px' }} />
<Column header={__('Schema rendicontazione', 'gepafin')} body={schemaStatusTpl} style={{ width: '180px' }} />
<Column header={__('Azioni', 'gepafin')} body={actionsTpl} style={{ width: '180px' }} />
</DataTable>
</Card>
</div>
);
};
export default RendicontazioneHome;

View File

@@ -0,0 +1,481 @@
import React, { useEffect, useState, useRef } from 'react';
import { __ } from '@wordpress/i18n';
import { useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
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';
const STATUS_TAGS = {
DRAFT: { severity: 'warning', label: 'In compilazione', icon: 'pi pi-pencil' },
SUBMITTED: { severity: 'info', label: 'Inviata', icon: 'pi pi-send' },
UNDER_REVIEW: { severity: 'info', label: 'In valutazione', icon: 'pi pi-eye' },
APPROVED: { severity: 'success', label: 'Approvata', icon: 'pi pi-check-circle' },
REJECTED: { severity: 'danger', label: 'Respinta', icon: 'pi pi-times-circle' },
AWAITING_AMENDMENT: { severity: 'warning', label: 'Soccorso istruttorio',icon: 'pi pi-exclamation-triangle' }
};
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 navigate = useNavigate();
const toast = useRef(null);
const [apps, setApps] = useState([]);
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 = () => {
setLoading(true);
RendicontazioneService.listMine(
(resp) => {
setApps(resp?.data?.applications || []);
setLoading(false);
},
(err) => {
toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail });
setLoading(false);
}
);
};
useEffect(() => { load(); /* eslint-disable-next-line */ }, []);
const openStartDialog = (app) => {
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) => {
setStarting(false);
setStartDialog(null);
toast.current?.show({ severity: 'success', summary: resp?.message || __('Tranche avviata', 'gepafin') });
navigate(`/rendicontazioni/${resp.data.id}`);
},
(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 renderApplicationCard = (app) => {
const tranchesCount = app.tranches?.length || 0;
const hasTranches = tranchesCount > 0;
const nextSeq = tranchesCount + 1;
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 headerTemplate = (
<div style={{
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>
);
return (
<Card key={app.application_id}
header={headerTemplate}
style={{ marginBottom: '1.5rem', borderRadius: '8px', overflow: 'hidden' }}>
{/* BLOCCO TOTALI — 4 stat tile affiancati */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
padding: '0.25rem 0.25rem 1rem',
}}>
<StatTile
label={__('Cap remissione totale', 'gepafin')}
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 (
<div className="appPage">
<Toast ref={toast} />
<div className="appPage__pageHeader">
<h1>{__('Le mie rendicontazioni', 'gepafin')}</h1>
<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 className="appPage__spacer"></div>
{loading && (
<div>
<Skeleton width="100%" height="14rem" style={{ marginBottom: '1rem' }} />
<Skeleton width="100%" height="14rem" />
</div>
)}
{!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')}
</small>
</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>
)}
>
{startDialog && (
<div>
<div style={{
padding: '0.75rem 1rem',
background: 'var(--surface-50)',
borderRadius: '6px',
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>
)}
</Dialog>
</div>
);
};
export default RendicontazioniMie;

View File

@@ -0,0 +1,601 @@
/**
* Client HTTP per rendicontazione-api (microservizio BFLOWS).
* Usa fetch nativa come NetworkService. Il microservizio valida lo stesso JWT di GEPAFIN-BE.
*
* Env var: REACT_APP_RENDICONTAZIONE_API_URL (es. http://78.46.41.91:18090)
*/
import { storeGet } from '../../../store';
const BASE_URL = process.env.REACT_APP_RENDICONTAZIONE_API_URL || '';
const buildHeaders = () => {
const token = storeGet('getToken');
const h = { 'Content-Type': 'application/json' };
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 RendicontazioneService = {
getSchemaByCallId(callId, onSuccess, onError) {
fetch(`${BASE_URL}/api/rendicontazione-schemas/${callId}`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
initializeRestartTemplate(callId, onSuccess, onError) {
fetch(`${BASE_URL}/api/rendicontazione-schemas/${callId}/initialize-restart`, {
method: 'POST', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
updateSchema(callId, schemaJson, onSuccess, onError) {
fetch(`${BASE_URL}/api/rendicontazione-schemas/${callId}`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ schema_json: schemaJson })
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
publishSchema(callId, onSuccess, onError) {
fetch(`${BASE_URL}/api/rendicontazione-schemas/${callId}/publish`, {
method: 'POST', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
deleteSchema(callId, onSuccess, onError) {
fetch(`${BASE_URL}/api/rendicontazione-schemas/${callId}`, {
method: 'DELETE', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
getRestartTemplatePreview(onSuccess, onError) {
fetch(`${BASE_URL}/api/rendicontazione-schemas/templates/restart`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
}
};
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 ======================
const extendPractice = {
listMine(onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/mine`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
startPractice(applicationId, onSuccess, onError, opts = {}) {
// opts: { period_label?: string, copy_ula_from_previous?: bool }
fetch(`${BASE_URL}/api/remission-practices/start`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
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));
},
getPractice(practiceId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
updatePractice(practiceId, patch, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(patch)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
addInvoice(practiceId, invoice, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/invoices`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(invoice)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
deleteInvoice(practiceId, invoiceId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/invoices/${invoiceId}`, {
method: 'DELETE', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
addUlaEmployee(practiceId, emp, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/ula-employees`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(emp)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
deleteUlaEmployee(practiceId, empId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/ula-employees/${empId}`, {
method: 'DELETE', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
upsertDocument(practiceId, docCode, payload, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/documents/${docCode}`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ doc_code: docCode, ...payload })
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
clearDocument(practiceId, docCode, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/documents/${docCode}`, {
method: 'DELETE', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
gateCheck(practiceId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/gate-check`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
submitPractice(practiceId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/submit`, {
method: 'POST', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
// dev-only: impersonation per test beneficiary
impersonate(email, onSuccess, onError) {
fetch(`${BASE_URL}/api/debug/impersonate`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ email })
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
}
};
// Attach to main export
Object.assign(RendicontazioneService, extendPractice);
// ====================== ISTRUTTORE ======================
const extendInstructor = {
instructorQueue(onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/queue`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
instructorViewPractice(practiceId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
claimPractice(practiceId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/claim`, {
method: 'POST', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
approvePractice(practiceId, body, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/approve`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(body || {})
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
rejectPractice(practiceId, reason, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/reject`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ rejection_reason: reason })
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
createAmendment(practiceId, body, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(body)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
closeAmendment(practiceId, amendmentId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/close`, {
method: 'POST', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
respondAmendmentBeneficiary(practiceId, amendmentId, responseText, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/respond-beneficiary`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ response_text: responseText })
}).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));
}
};
Object.assign(RendicontazioneService, extendInstructor);
// ====================== VERIFICA SINGOLA RIGA ISTRUTTORE ======================
const extendVerify = {
verifyInvoice(practiceId, invoiceId, body, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/invoices/${invoiceId}/verify`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(body)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
verifyUlaEmployee(practiceId, empId, body, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/ula-employees/${empId}/verify`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(body)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
verifyDocument(practiceId, docCode, body, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/documents/${docCode}/verify`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(body)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
setInstructorFinalNotes(practiceId, body, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/final-notes`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(body)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
}
};
Object.assign(RendicontazioneService, extendVerify);
// ====================== FILE UPLOAD ======================
const _buildBearerOnly = () => {
const token = storeGet('getToken');
return token ? { 'Authorization': `Bearer ${token}` } : {};
};
const extendFiles = {
/**
* Upload file per entita (invoice/ula/document).
* Restituisce i metadata del file via onSuccess.
*/
uploadEntityFile(entityType, entityId, file, onSuccess, onError) {
const fd = new FormData();
fd.append('file', file);
fetch(`${BASE_URL}/api/remission-files/${entityType}/${entityId}/upload`, {
method: 'POST', mode: 'cors',
headers: _buildBearerOnly(), // no Content-Type: browser mette boundary
body: fd
}).then(r => handleResponse(r, onSuccess, 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.
*/
deleteEntityFile(entityType, entityId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-files/${entityType}/${entityId}`, {
method: 'DELETE', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
/**
* Fetch file come Blob (per preview in iframe tramite object URL).
* onSuccess({blob, objectUrl, filename}).
*/
fetchEntityFileBlob(entityType, entityId, inline, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-files/${entityType}/${entityId}?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;
}
// estrae filename da Content-Disposition
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));
},
/**
* Download forzato: apre finestra "save as" del browser.
*/
downloadEntityFile(entityType, entityId, onError) {
this.fetchEntityFileBlob(entityType, entityId, 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);
},
onError
);
}
};
Object.assign(RendicontazioneService, extendFiles);
// ====================== VERBALE ISTRUTTORIA ======================
const extendVerbale = {
/**
* Scarica il verbale di istruttoria come PDF (download forzato).
*/
downloadVerbale(practiceId, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/verbale.pdf`, {
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;
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `verbale_istruttoria_${practiceId.slice(0, 8)}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 60000);
}).catch(e => { if (onError) onError({ status: 0, detail: e.message }); });
},
/**
* Apre preview HTML del verbale in una nuova tab (debug rapido).
*/
async openVerbaleHtml(practiceId) {
const token = storeGet('getToken');
const r = await fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/verbale.html`, {
method: 'GET', mode: 'cors',
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
});
if (!r.ok) throw new Error('Verbale HTML fetch failed');
const html = await r.text();
const w = window.open('', '_blank');
if (w) { w.document.open(); w.document.write(html); w.document.close(); }
}
};
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

@@ -89,6 +89,10 @@ const BandoEdit = () => {
navigate(`/bandi/${id}/flow`); navigate(`/bandi/${id}/flow`);
} }
const openRendicontazioneSchema = () => {
navigate(`/bandi/${id}/rendicontazione-schema`);
}
const validateBando = () => { const validateBando = () => {
storeSet('setAsyncRequest'); storeSet('setAsyncRequest');
bandoMsgs.current.clear(); bandoMsgs.current.clear();
@@ -408,6 +412,22 @@ const BandoEdit = () => {
: <p>{__('Nessun modulo creato ancora', 'gepafin')}</p>} : <p>{__('Nessun modulo creato ancora', 'gepafin')}</p>}
</div> </div>
<div className="appPageSection">
<h2>{__('Schema di rendicontazione', 'gepafin')}</h2>
<p className="text-color-secondary">
{__('Configura come i beneficiari dovranno rendicontare dopo la firma del contratto: categorie di spesa, ULA, documenti richiesti.', 'gepafin')}
</p>
<div className="row">
<Button
type="button"
outlined={data.status === 'PUBLISH'}
onClick={openRendicontazioneSchema}
icon="pi pi-receipt"
iconPos="right"
label={__('Crea o modifica schema di rendicontazione', 'gepafin')}/>
</div>
</div>
<div className="appPage__spacer"></div> <div className="appPage__spacer"></div>
<div className="appPageSection"> <div className="appPageSection">

View File

@@ -14,6 +14,13 @@ import BandoView from './pages/BandoView';
import BandoFormsEdit from './pages/BandoFormsEdit'; import BandoFormsEdit from './pages/BandoFormsEdit';
import BandoForms from './pages/BandoForms'; import BandoForms from './pages/BandoForms';
import BandoFormsPreview from './pages/BandoFormsPreview'; import BandoFormsPreview from './pages/BandoFormsPreview';
import BandoRendicontazioneSchemaEdit from './modules/rendicontazione/pages/BandoRendicontazioneSchemaEdit';
import RendicontazioneHome from './modules/rendicontazione/pages/RendicontazioneHome';
import RendicontazioniMie from './modules/rendicontazione/pages/RendicontazioniMie';
import PraticaRendicontazioneEdit from './modules/rendicontazione/pages/PraticaRendicontazioneEdit';
import DevSwitchUser from './modules/rendicontazione/pages/DevSwitchUser';
import IstruttoriaQueue from './modules/rendicontazione/pages/IstruttoriaQueue';
import IstruttoriaPratica from './modules/rendicontazione/pages/IstruttoriaPratica';
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';
@@ -135,6 +142,51 @@ 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="/rendicontazione" element={<DefaultLayout>
{'ROLE_SUPER_ADMIN' === role ? <RendicontazioneHome/> : <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="/bandi/:id/rendicontazione-schema" element={<DefaultLayout>
{'ROLE_SUPER_ADMIN' === role ? <BandoRendicontazioneSchemaEdit/> : <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" element={<DefaultLayout>
{'ROLE_BENEFICIARY' === role ? <RendicontazioniMie/> : null}
{'ROLE_SUPER_ADMIN' === role ? <RendicontazioniMie/> : 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>
{'ROLE_BENEFICIARY' === role ? <PraticaRendicontazioneEdit/> : null}
{'ROLE_SUPER_ADMIN' === role ? <PraticaRendicontazioneEdit/> : null}
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
</DefaultLayout>}/>
<Route path="/dev-switch-user" element={<DefaultLayout>
{'ROLE_SUPER_ADMIN' === role ? <DevSwitchUser/> : <PageNotFound/>}
</DefaultLayout>}/>
<Route path="/istruttoria" element={<DefaultLayout>
{'ROLE_PRE_INSTRUCTOR' === role ? <IstruttoriaQueue/> : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? <IstruttoriaQueue/> : null}
{'ROLE_SUPER_ADMIN' === role ? <IstruttoriaQueue/> : null}
{'ROLE_BENEFICIARY' === role ? <PageNotFound/> : null}
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
</DefaultLayout>}/>
<Route path="/istruttoria/:id" element={<DefaultLayout>
{'ROLE_PRE_INSTRUCTOR' === role ? <IstruttoriaPratica/> : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? <IstruttoriaPratica/> : null}
{'ROLE_SUPER_ADMIN' === role ? <IstruttoriaPratica/> : null}
{'ROLE_BENEFICIARY' === role ? <PageNotFound/> : null}
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
</DefaultLayout>}/>
<Route path="/bandi-osservati" element={<DefaultLayout> <Route path="/bandi-osservati" element={<DefaultLayout>
{'ROLE_SUPER_ADMIN' === role ? <PageNotFound/> : null} {'ROLE_SUPER_ADMIN' === role ? <PageNotFound/> : null}
{'ROLE_BENEFICIARY' === role ? <BandiPreferredBeneficiario/> : null} {'ROLE_BENEFICIARY' === role ? <BandiPreferredBeneficiario/> : null}